diff --git a/app.py b/app.py index fdf2e45..8834f4d 100644 --- a/app.py +++ b/app.py @@ -268,6 +268,7 @@ def init_db(): 'ALTER TABLE jobs ADD COLUMN plate_name TEXT DEFAULT ""', 'ALTER TABLE printers ADD COLUMN ha_entity_prefix TEXT DEFAULT ""', 'ALTER TABLE print_slots ADD COLUMN pieces_ignored INTEGER DEFAULT 0', + 'ALTER TABLE print_slots ADD COLUMN pieces_override INTEGER DEFAULT NULL', ] for sql in migrations: try: @@ -1631,6 +1632,15 @@ def api_slots(): SELECT ps.id, ps.job_id, ps.printer_id, ps.planned_start, ps.planned_end, ps.status, ps.failure_note, COALESCE(ps.pieces_ignored, 0) as pieces_ignored, + ps.pieces_override, + COALESCE(ps.pieces_override, j.pieces_per_plate) as effective_pieces, + (SELECT COUNT(*) FROM print_slots ps2 + WHERE ps2.job_id = ps.job_id + AND ps2.planned_start <= ps.planned_start + AND ps2.id <= ps.id) as plate_num, + (SELECT COUNT(*) FROM print_slots WHERE job_id = ps.job_id) as total_plates, + (SELECT COALESCE(SUM(COALESCE(pieces_ignored,0)), 0) FROM print_slots + WHERE job_id = ps.job_id) as job_total_ignored, j.name as job_name, j.print_time_s, j.final_price, j.pieces_per_plate, j.order_qty, j.weight_g, j.plate_name, @@ -1647,6 +1657,21 @@ def api_slots(): conn.close() return jsonify([dict(s) for s in slots]) +@app.route('/api/slots//pieces_override', methods=['POST']) +@login_required +def api_slot_pieces_override(id): + d = request.get_json(silent=True) or {} + pieces = d.get('pieces') + conn = get_db() + if pieces is None: + conn.execute('UPDATE print_slots SET pieces_override=NULL WHERE id=?', (id,)) + else: + conn.execute('UPDATE print_slots SET pieces_override=? WHERE id=?', (max(1, int(pieces)), id)) + conn.commit() + conn.close() + return jsonify({'ok': True}) + + def _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=None): """Vérifie si ajouter ce créneau dépasserait max_printers simultanés. Retourne (True, peak_count) si limite atteinte, (False, peak_count) sinon. diff --git a/templates/planning.html b/templates/planning.html index 163a322..833359e 100644 --- a/templates/planning.html +++ b/templates/planning.html @@ -376,44 +376,134 @@ function renderSlotsList(slots) { el.innerHTML = '

Aucun créneau planifié.

'; return; } - const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 20); + const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 30); + + // Pre-compute per-job stats using job_total_ignored from API (covers all slots, even old ones) + const jobMeta = {}; + for (const s of upcoming) { + if (!jobMeta[s.job_id]) { + jobMeta[s.job_id] = { + name: s.job_name, + totalPlates: s.total_plates, + totalIgnored: s.job_total_ignored, // sum across ALL slots of job + pendingSlots: [] + }; + } + if (['planned','running'].includes(s.status)) jobMeta[s.job_id].pendingSlots.push(s); + } + + let rows = ''; + const seenJobs = new Set(); + + for (const s of upcoming) { + const job = jobMeta[s.job_id]; + const isMulti = job.totalPlates > 1; + + // ── Job group header (once per multi-plate job) ────────────────────── + if (isMulti && !seenJobs.has(s.job_id)) { + seenJobs.add(s.job_id); + const deficit = job.totalIgnored; + const alreadyCompensated = job.pendingSlots.reduce((sum, sl) => + sum + (sl.pieces_override !== null ? Math.max(0, sl.pieces_override - sl.pieces_per_plate) : 0), 0); + const remaining = deficit - alreadyCompensated; + const defBadge = remaining > 0 + ? ` + ${remaining} pièce(s) à compenser + ` + : (deficit > 0 + ? `Compensé` + : ''); + rows += ` + + + ${s.job_name} — ${job.totalPlates} plateaux + ${defBadge} + + `; + } + + // ── Individual slot row ────────────────────────────────────────────── + const h = Math.floor(s.print_time_s / 3600); + const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0'); + const dur = `${h}h${mn}`; + const dot = s.material_color + ? `` + : ''; + + // Plate label: for multi-plate show "Plateau X/Y", else fallback to plate_name + const platePart = isMulti + ? `
Plateau ${s.plate_num}/${s.total_plates}` + : (s.plate_name + ? `
${s.plate_name}` + : ''); + + const filPart = s.material_name + ? `
${dot}${s.material_name} ${s.material_type||''}` + : ''; + const clientPart = s.client_name + ? `
${s.client_name}` + : ''; + + // Pieces delivered on this slot (if already printed with ignores) + const ignoredPart = s.pieces_ignored > 0 + ? `
+ ${s.effective_pieces - s.pieces_ignored}/${s.effective_pieces} pièces livrées + ` + : ''; + + // Override badge (slot was manually adjusted) + const overridePart = s.pieces_override !== null + ? `
+ ${s.pieces_override} pièces (ajusté) + ` + : ''; + + // Compensation needed on this pending slot + const isPending = ['planned','running'].includes(s.status); + const deficit = job.totalIgnored; + const alreadyComp = job.pendingSlots.reduce((sum, sl) => + sum + (sl.pieces_override !== null ? Math.max(0, sl.pieces_override - sl.pieces_per_plate) : 0), 0); + const stillNeeded = deficit - alreadyComp; + const compensatePart = (isPending && stillNeeded > 0 && s.pieces_ignored === 0 && s.pieces_override === null) + ? `
+ Compenser : imprimer ${s.effective_pieces + stillNeeded} ✏️ + ` + : ''; + + // For multi-plate jobs, don't repeat the job name in each row (it's in the header) + const jobLabel = isMulti ? '' : `${s.job_name}`; + + rows += ` + ${formatDt(s.planned_start)} + ${jobLabel}${platePart}${filPart}${clientPart}${ignoredPart}${overridePart}${compensatePart} + ${dur} + ${s.printer_name} + ${STATUS_LABELS[s.status]||s.status} + `; + } + el.innerHTML = `
- + - - ${upcoming.map(s => { - const h = Math.floor(s.print_time_s / 3600); - const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0'); - const dur = `${h}h${mn}`; - const dot = s.material_color - ? `` - : ''; - const platePart = s.plate_name - ? `
${s.plate_name}` - : ''; - const filPart = s.material_name - ? `
${dot}${s.material_name} ${s.material_type||''}` - : ''; - const clientPart = s.client_name - ? `
${s.client_name}` - : ''; - const ignoredPart = (s.pieces_ignored > 0) - ? `
${s.pieces_per_plate - s.pieces_ignored}/${s.pieces_per_plate} pièces` - : ''; - return ` - - - - - - `; - }).join('')} -
DébutJobJob / Plateau Durée Machine Statut
${formatDt(s.planned_start)}${s.job_name}${platePart}${ignoredPart}${filPart}${clientPart}${dur}${s.printer_name}${STATUS_LABELS[s.status]||s.status}
`; + ${rows} + `; +} + +async function applyCompensation(slotId, currentPieces, deficit) { + const newVal = currentPieces + deficit; + if (!confirm(`Ajuster ce plateau à ${newVal} pièces (au lieu de ${currentPieces}) pour compenser les ${deficit} pièce(s) ignorée(s) ?`)) return; + const res = await fetch(`/api/slots/${slotId}/pieces_override`, { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({pieces: newVal}) + }).then(r => r.json()); + if (res.ok) loadSlots(); } function formatDt(iso) {