diff --git a/app.py b/app.py index a17115b..a0fcef0 100644 --- a/app.py +++ b/app.py @@ -1304,8 +1304,10 @@ def planning(): ).fetchall() schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall() conn.close() + settings = get_settings() return render_template('planning.html', printers=printers, - unscheduled=unscheduled, blocks=blocks, schedule=schedule) + unscheduled=unscheduled, blocks=blocks, schedule=schedule, + settings=settings) @app.route('/printers', methods=['GET','POST']) def printers_page(): @@ -1620,17 +1622,71 @@ def api_slots(): ps.status, ps.failure_note, 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, p.name as printer_name, - c.name as client_name + c.name as client_name, + m.name as material_name, m.type as material_type, m.color as material_color FROM print_slots ps JOIN jobs j ON ps.job_id=j.id JOIN printers p ON ps.printer_id=p.id LEFT JOIN clients c ON j.client_id=c.id + LEFT JOIN materials m ON j.material_id=m.id WHERE ps.planned_start >= datetime('now', '-7 days') ORDER BY ps.planned_start''').fetchall() conn.close() return jsonify([dict(s) for s in slots]) +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. + Méthode : on évalue le nombre de slots actifs à chaque 'event point' + (toutes les heures de début des créneaux qui chevauchent la fenêtre). + """ + q = """SELECT planned_start, planned_end FROM print_slots + WHERE status NOT IN ('cancelled','failed','done') + AND planned_start < ? AND planned_end > ?""" + params = [end_dt.isoformat(), start_dt.isoformat()] + if exclude_id: + q += ' AND id != ?' + params.append(exclude_id) + overlapping = conn.execute(q, params).fetchall() + + if len(overlapping) < max_printers: + return False, len(overlapping) # pas assez de chevauchements pour atteindre la limite + + # Points à tester : le début du créneau proposé + début de chaque slot existant dans la fenêtre + event_times = [start_dt] + for sl in overlapping: + t = datetime.fromisoformat(sl['planned_start']) + if start_dt <= t < end_dt: + event_times.append(t) + + peak = 0 + for t in event_times: + count = sum( + 1 for sl in overlapping + if datetime.fromisoformat(sl['planned_start']) <= t + < datetime.fromisoformat(sl['planned_end']) + ) + if count > peak: + peak = count + if count >= max_printers: + return True, count + return False, peak + +def _check_overlap(conn, printer_id, start_dt, end_dt, exclude_id=None): + """Retourne le slot en conflit s'il existe, sinon None.""" + q = """SELECT ps.id, j.name as job_name, ps.planned_start, ps.planned_end + FROM print_slots ps JOIN jobs j ON ps.job_id=j.id + WHERE ps.printer_id=? + AND ps.status NOT IN ('cancelled','failed','done') + AND ps.planned_start < ? AND ps.planned_end > ?""" + params = [printer_id, end_dt.isoformat(), start_dt.isoformat()] + if exclude_id: + q += ' AND ps.id != ?' + params.append(exclude_id) + return conn.execute(q, params).fetchone() + @app.route('/api/slots/new', methods=['POST']) def api_new_slot(): d = request.json @@ -1646,6 +1702,16 @@ def api_new_slot(): cooldown_s = int(s.get('cooldown_minutes', 15)) * 60 start_dt = datetime.fromisoformat(start_str) end_dt = start_dt + timedelta(seconds=job['print_time_s'] + cooldown_s) + conflict = _check_overlap(conn, printer_id, start_dt, end_dt) + if conflict: + conn.close() + end_conf = conflict['planned_end'][:16].replace('T', ' ') + return jsonify({'error': f"Chevauchement avec « {conflict['job_name']} » (fin prévue {end_conf}). Choisissez un autre horaire ou une autre imprimante."}), 409 + max_printers = int(s.get('max_simultaneous_printers', 5)) + over, peak = _check_capacity(conn, start_dt, end_dt, max_printers) + if over: + conn.close() + return jsonify({'error': f"Capacité maximale atteinte : {peak} impression(s) déjà en cours sur ce créneau (limite : {max_printers} simultanées). Décalez l'horaire ou attendez qu'une imprimante se libère."}), 409 conn.execute('INSERT INTO print_slots(job_id,printer_id,planned_start,planned_end,status) VALUES(?,?,?,?,?)', (job_id, printer_id, start_dt.isoformat(), end_dt.isoformat(), 'planned')) conn.commit(); conn.close() @@ -1664,6 +1730,16 @@ def api_move_slot(id): cooldown_s = int(s.get('cooldown_minutes', 15)) * 60 start_dt = datetime.fromisoformat(start_str) end_dt = start_dt + timedelta(seconds=slot['print_time_s'] + cooldown_s) + conflict = _check_overlap(conn, slot['printer_id'], start_dt, end_dt, exclude_id=id) + if conflict: + conn.close() + end_conf = conflict['planned_end'][:16].replace('T', ' ') + return jsonify({'error': f"Chevauchement avec « {conflict['job_name']} » (fin prévue {end_conf})."}), 409 + max_printers = int(s.get('max_simultaneous_printers', 5)) + over, peak = _check_capacity(conn, start_dt, end_dt, max_printers, exclude_id=id) + if over: + conn.close() + return jsonify({'error': f"Capacité maximale atteinte : {peak} impression(s) en cours sur ce créneau (limite : {max_printers} simultanées)."}), 409 conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?', (start_dt.isoformat(), end_dt.isoformat(), id)) conn.commit(); conn.close() diff --git a/templates/base.html b/templates/base.html index 6b8075a..358d3aa 100644 --- a/templates/base.html +++ b/templates/base.html @@ -105,10 +105,49 @@ .stock-ok { color: #22c55e; } .stock-low { color: #f59e0b; } .stock-empty { color: #ef4444; } + + /* ── Mobile responsive ───────────────────────────────────────────────── */ + @media (max-width: 767.98px) { + #sidebar { display: none !important; } + #main { margin-left: 0 !important; padding: 1rem 0.75rem; } + /* Empêcher le zoom auto iOS sur les inputs */ + input, select, textarea { font-size: max(1rem, 16px) !important; } + .card { border-radius: 8px; } + .stat-card { padding: 1rem; } + .stat-card .value { font-size: 1.4rem; } + .final-price { font-size: 1.5rem; } + }
+ + + + +