feat: max simultaneous printers enforced + capacity bands on Gantt
Deploy via Portainer / deploy (push) Successful in 0s
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -1304,8 +1304,10 @@ def planning():
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall()
|
schedule = conn.execute('SELECT * FROM working_schedule ORDER BY day_of_week').fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
settings = get_settings()
|
||||||
return render_template('planning.html', printers=printers,
|
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'])
|
@app.route('/printers', methods=['GET','POST'])
|
||||||
def printers_page():
|
def printers_page():
|
||||||
@@ -1620,17 +1622,71 @@ def api_slots():
|
|||||||
ps.status, ps.failure_note,
|
ps.status, ps.failure_note,
|
||||||
j.name as job_name, j.print_time_s, j.final_price,
|
j.name as job_name, j.print_time_s, j.final_price,
|
||||||
j.pieces_per_plate, j.order_qty, j.weight_g,
|
j.pieces_per_plate, j.order_qty, j.weight_g,
|
||||||
|
j.plate_name,
|
||||||
p.name as printer_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
|
FROM print_slots ps
|
||||||
JOIN jobs j ON ps.job_id=j.id
|
JOIN jobs j ON ps.job_id=j.id
|
||||||
JOIN printers p ON ps.printer_id=p.id
|
JOIN printers p ON ps.printer_id=p.id
|
||||||
LEFT JOIN clients c ON j.client_id=c.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')
|
WHERE ps.planned_start >= datetime('now', '-7 days')
|
||||||
ORDER BY ps.planned_start''').fetchall()
|
ORDER BY ps.planned_start''').fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify([dict(s) for s in slots])
|
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'])
|
@app.route('/api/slots/new', methods=['POST'])
|
||||||
def api_new_slot():
|
def api_new_slot():
|
||||||
d = request.json
|
d = request.json
|
||||||
@@ -1646,6 +1702,16 @@ def api_new_slot():
|
|||||||
cooldown_s = int(s.get('cooldown_minutes', 15)) * 60
|
cooldown_s = int(s.get('cooldown_minutes', 15)) * 60
|
||||||
start_dt = datetime.fromisoformat(start_str)
|
start_dt = datetime.fromisoformat(start_str)
|
||||||
end_dt = start_dt + timedelta(seconds=job['print_time_s'] + cooldown_s)
|
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(?,?,?,?,?)',
|
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'))
|
(job_id, printer_id, start_dt.isoformat(), end_dt.isoformat(), 'planned'))
|
||||||
conn.commit(); conn.close()
|
conn.commit(); conn.close()
|
||||||
@@ -1664,6 +1730,16 @@ def api_move_slot(id):
|
|||||||
cooldown_s = int(s.get('cooldown_minutes', 15)) * 60
|
cooldown_s = int(s.get('cooldown_minutes', 15)) * 60
|
||||||
start_dt = datetime.fromisoformat(start_str)
|
start_dt = datetime.fromisoformat(start_str)
|
||||||
end_dt = start_dt + timedelta(seconds=slot['print_time_s'] + cooldown_s)
|
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=?',
|
conn.execute('UPDATE print_slots SET planned_start=?,planned_end=? WHERE id=?',
|
||||||
(start_dt.isoformat(), end_dt.isoformat(), id))
|
(start_dt.isoformat(), end_dt.isoformat(), id))
|
||||||
conn.commit(); conn.close()
|
conn.commit(); conn.close()
|
||||||
|
|||||||
@@ -105,10 +105,49 @@
|
|||||||
.stock-ok { color: #22c55e; }
|
.stock-ok { color: #22c55e; }
|
||||||
.stock-low { color: #f59e0b; }
|
.stock-low { color: #f59e0b; }
|
||||||
.stock-empty { color: #ef4444; }
|
.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; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- ── Mobile top bar (d-md-none = visible seulement < 768px) ─────────── -->
|
||||||
|
<nav id="mobileTopBar"
|
||||||
|
class="d-flex d-md-none align-items-center sticky-top px-3 gap-2"
|
||||||
|
style="background:#1a1d23;height:52px;z-index:1100">
|
||||||
|
<button class="btn p-1" style="color:#9ba3af;font-size:1.3rem;line-height:1"
|
||||||
|
data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Menu">
|
||||||
|
<i class="bi bi-list"></i>
|
||||||
|
</button>
|
||||||
|
<span class="text-white fw-bold">
|
||||||
|
<i class="bi bi-layers-half me-1"></i>3D<span style="color:#ff6b35">Pricing</span>
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── Mobile offcanvas sidebar ──────────────────────────────────────── -->
|
||||||
|
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar"
|
||||||
|
style="background:#1a1d23;width:240px">
|
||||||
|
<div class="offcanvas-header" style="border-bottom:1px solid #2e323d;padding:.75rem 1.25rem">
|
||||||
|
<span class="text-white fw-bold">
|
||||||
|
<i class="bi bi-layers-half me-1"></i>3D<span style="color:#ff6b35">Pricing</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body p-0" style="overflow-y:auto">
|
||||||
|
<div id="mobileNavInner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="sidebar">
|
<div id="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand-text"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></span>
|
<span class="brand-text"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></span>
|
||||||
@@ -221,6 +260,33 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<!-- ── Peuple l'offcanvas mobile depuis le sidebar desktop ───────────── -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const sidebarNav = document.querySelector('#sidebar nav');
|
||||||
|
const sidebarUser = document.querySelector('#sidebar .sidebar-user');
|
||||||
|
const mobileInner = document.getElementById('mobileNavInner');
|
||||||
|
if (!sidebarNav || !mobileInner) return;
|
||||||
|
|
||||||
|
// Clone le nav et force l'affichage des labels (jamais en mode collapsed)
|
||||||
|
const navClone = sidebarNav.cloneNode(true);
|
||||||
|
navClone.querySelectorAll('.nav-label, .nav-section').forEach(el => {
|
||||||
|
el.style.opacity = '1';
|
||||||
|
el.style.pointerEvents = 'auto';
|
||||||
|
});
|
||||||
|
// Ferme l'offcanvas au clic sur un lien
|
||||||
|
navClone.querySelectorAll('a').forEach(a => a.setAttribute('data-bs-dismiss', 'offcanvas'));
|
||||||
|
mobileInner.appendChild(navClone);
|
||||||
|
|
||||||
|
// Clone la zone utilisateur en bas
|
||||||
|
if (sidebarUser) {
|
||||||
|
const userClone = sidebarUser.cloneNode(true);
|
||||||
|
userClone.style.cssText += ';opacity:1;pointer-events:auto;margin-top:auto';
|
||||||
|
userClone.querySelectorAll('a').forEach(a => a.setAttribute('data-bs-dismiss', 'offcanvas'));
|
||||||
|
mobileInner.appendChild(userClone);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header py-3">Informations</div>
|
<div class="card-header py-3">Informations</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
|
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Matiere</th>
|
<tr><th class="text-muted fw-normal">Matiere</th>
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
|
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% if job.notes %}
|
{% if job.notes %}
|
||||||
<hr>
|
<hr>
|
||||||
<small class="text-muted">{{ job.notes }}</small>
|
<small class="text-muted">{{ job.notes }}</small>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if jobs %}
|
{% if jobs %}
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center text-muted py-5">
|
<div class="text-center text-muted py-5">
|
||||||
<i class="bi bi-inbox fs-1"></i>
|
<i class="bi bi-inbox fs-1"></i>
|
||||||
|
|||||||
+116
-19
@@ -126,6 +126,9 @@
|
|||||||
<div id="modalSuggestion" class="alert alert-success py-2 small d-none">
|
<div id="modalSuggestion" class="alert alert-success py-2 small d-none">
|
||||||
<i class="bi bi-magic me-1"></i><span id="modalSuggestionText"></span>
|
<i class="bi bi-magic me-1"></i><span id="modalSuggestionText"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modalError" class="alert alert-danger py-2 small d-none">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i><span></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||||
@@ -215,6 +218,58 @@ const STATUS_LABELS = {
|
|||||||
|
|
||||||
let timeline, items, groups;
|
let timeline, items, groups;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule les périodes où le nombre de slots actifs atteint max_printers
|
||||||
|
* et retourne des items background vis.js pour les afficher en rouge.
|
||||||
|
*/
|
||||||
|
function buildCapacityBands(slots, maxPrinters) {
|
||||||
|
const active = slots.filter(s => !['cancelled','failed','done'].includes(s.status));
|
||||||
|
if (active.length < maxPrinters) return [];
|
||||||
|
|
||||||
|
// Collecter tous les événements (début/fin)
|
||||||
|
const events = [];
|
||||||
|
active.forEach(s => {
|
||||||
|
events.push({ t: new Date(s.planned_start), d: +1 });
|
||||||
|
events.push({ t: new Date(s.planned_end), d: -1 });
|
||||||
|
});
|
||||||
|
events.sort((a, b) => a.t - b.t);
|
||||||
|
|
||||||
|
const bands = [];
|
||||||
|
let count = 0;
|
||||||
|
let bandStart = null;
|
||||||
|
let bgId = 0;
|
||||||
|
|
||||||
|
for (const ev of events) {
|
||||||
|
count += ev.d;
|
||||||
|
if (count >= maxPrinters && bandStart === null) {
|
||||||
|
bandStart = ev.t;
|
||||||
|
} else if (count < maxPrinters && bandStart !== null) {
|
||||||
|
bands.push({
|
||||||
|
id: 'cap_' + (bgId++),
|
||||||
|
start: bandStart,
|
||||||
|
end: ev.t,
|
||||||
|
type: 'background',
|
||||||
|
className: '',
|
||||||
|
style: 'background: rgba(239,68,68,.18); border-left: 2px solid rgba(239,68,68,.5);',
|
||||||
|
title: `⚠️ Capacité max atteinte (${maxPrinters} impressions simultanées)`,
|
||||||
|
});
|
||||||
|
bandStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bandStart !== null) {
|
||||||
|
bands.push({
|
||||||
|
id: 'cap_' + (bgId++),
|
||||||
|
start: bandStart,
|
||||||
|
end: new Date(bandStart.getTime() + 86400000),
|
||||||
|
type: 'background',
|
||||||
|
className: '',
|
||||||
|
style: 'background: rgba(239,68,68,.18); border-left: 2px solid rgba(239,68,68,.5);',
|
||||||
|
title: `⚠️ Capacité max atteinte (${maxPrinters} impressions simultanées)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return bands;
|
||||||
|
}
|
||||||
|
|
||||||
async function initTimeline() {
|
async function initTimeline() {
|
||||||
const [slotsRes, availRes] = await Promise.all([
|
const [slotsRes, availRes] = await Promise.all([
|
||||||
fetch('/api/slots').then(r => r.json()),
|
fetch('/api/slots').then(r => r.json()),
|
||||||
@@ -239,17 +294,33 @@ async function initTimeline() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Print slots
|
// Print slots
|
||||||
const slotItems = slotsRes.map(s => ({
|
const slotItems = slotsRes.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
|
||||||
|
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.material_color};border:1px solid rgba(255,255,255,.5);margin-right:3px;flex-shrink:0;vertical-align:middle"></span>`
|
||||||
|
: '';
|
||||||
|
const platePart = s.plate_name ? ` · ${s.plate_name}` : '';
|
||||||
|
const filLine = s.material_name ? `<br>🧵 ${s.material_name} ${s.material_type || ''}` : '';
|
||||||
|
const clientLine = s.client_name ? `<br>👤 ${s.client_name}` : '';
|
||||||
|
const plateLine = s.plate_name ? `<br>📋 ${s.plate_name}` : '';
|
||||||
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
group: s.printer_id,
|
group: s.printer_id,
|
||||||
start: s.planned_start,
|
start: s.planned_start,
|
||||||
end: s.planned_end,
|
end: s.planned_end,
|
||||||
content: `<span style="font-size:.7rem">${s.job_name}</span>`,
|
content: `<span style="font-size:.7rem;display:flex;align-items:center;gap:2px;white-space:nowrap">${dot}<strong>${s.job_name}</strong><span style="opacity:.75;font-weight:normal"> (${dur})</span></span>`,
|
||||||
title: `${s.job_name}${s.client_name ? ' — ' + s.client_name : ''}<br>${STATUS_LABELS[s.status] || s.status}`,
|
title: `<strong>${s.job_name}</strong>${plateLine}${clientLine}<br>⏱ ${dur}${filLine}<br>${STATUS_LABELS[s.status] || s.status}`,
|
||||||
style: `background:${STATUS_COLORS[s.status] || '#3b82f6'};border-color:${STATUS_COLORS[s.status] || '#3b82f6'};color:#fff;border-radius:4px`,
|
style: `background:${STATUS_COLORS[s.status]||'#3b82f6'};border-color:${STATUS_COLORS[s.status]||'#3b82f6'};color:#fff;border-radius:4px`,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
items = new vis.DataSet([...bgItems, ...slotItems]);
|
// Bandes rouges "capacité saturée" (toutes imprimantes, groupe null = full-width background)
|
||||||
|
const MAX_PRINTERS = {{ settings.get('max_simultaneous_printers', 5) }};
|
||||||
|
const capacityBgItems = buildCapacityBands(slotsRes, MAX_PRINTERS);
|
||||||
|
|
||||||
|
items = new vis.DataSet([...bgItems, ...slotItems, ...capacityBgItems]);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
timeline = new vis.Timeline(
|
timeline = new vis.Timeline(
|
||||||
@@ -290,20 +361,41 @@ function renderSlotsList(slots) {
|
|||||||
el.innerHTML = '<div class="text-center text-muted py-4 small"><i class="bi bi-calendar fs-3"></i><p class="mt-2">Aucun créneau planifié.</p></div>';
|
el.innerHTML = '<div class="text-center text-muted py-4 small"><i class="bi bi-calendar fs-3"></i><p class="mt-2">Aucun créneau planifié.</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 15);
|
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 20);
|
||||||
el.innerHTML = `<table class="table table-sm table-hover mb-0">
|
el.innerHTML = `<div class="table-responsive"><table class="table table-sm table-hover mb-0">
|
||||||
<thead class="table-light"><tr>
|
<thead class="table-light"><tr>
|
||||||
<th class="small">Début</th><th class="small">Job</th>
|
<th class="small">Début</th>
|
||||||
<th class="small">Machine</th><th class="small">Statut</th>
|
<th class="small">Job</th>
|
||||||
|
<th class="small">Durée</th>
|
||||||
|
<th class="small">Machine</th>
|
||||||
|
<th class="small">Statut</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${upcoming.map(s => `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'"')})">
|
${upcoming.map(s => {
|
||||||
<td class="small">${formatDt(s.planned_start)}</td>
|
const h = Math.floor(s.print_time_s / 3600);
|
||||||
<td class="small fw-semibold">${s.job_name}${s.client_name ? '<br><span class=\'text-muted fw-normal\'>' + s.client_name + '</span>' : ''}</td>
|
const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0');
|
||||||
<td class="small">${s.printer_name}</td>
|
const dur = `${h}h${mn}`;
|
||||||
|
const dot = s.material_color
|
||||||
|
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.material_color};border:1px solid #ccc;margin-right:3px;vertical-align:middle"></span>`
|
||||||
|
: '';
|
||||||
|
const platePart = s.plate_name
|
||||||
|
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>${s.plate_name}</span>`
|
||||||
|
: '';
|
||||||
|
const filPart = s.material_name
|
||||||
|
? `<br><span style="font-size:.7rem;color:#6b7280">${dot}${s.material_name} ${s.material_type||''}</span>`
|
||||||
|
: '';
|
||||||
|
const clientPart = s.client_name
|
||||||
|
? `<br><span class="text-muted fw-normal" style="font-size:.75rem">${s.client_name}</span>`
|
||||||
|
: '';
|
||||||
|
return `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'"')})">
|
||||||
|
<td class="small" style="white-space:nowrap">${formatDt(s.planned_start)}</td>
|
||||||
|
<td class="small fw-semibold">${s.job_name}${platePart}${filPart}${clientPart}</td>
|
||||||
|
<td class="small text-muted" style="white-space:nowrap">${dur}</td>
|
||||||
|
<td class="small" style="white-space:nowrap">${s.printer_name}</td>
|
||||||
<td><span class="badge" style="background:${STATUS_COLORS[s.status]}">${STATUS_LABELS[s.status]||s.status}</span></td>
|
<td><span class="badge" style="background:${STATUS_COLORS[s.status]}">${STATUS_LABELS[s.status]||s.status}</span></td>
|
||||||
</tr>`).join('')}
|
</tr>`;
|
||||||
</tbody></table>`;
|
}).join('')}
|
||||||
|
</tbody></table></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDt(iso) {
|
function formatDt(iso) {
|
||||||
@@ -350,16 +442,21 @@ async function saveSlot() {
|
|||||||
const jobId = parseInt(document.getElementById('modalJobId').value);
|
const jobId = parseInt(document.getElementById('modalJobId').value);
|
||||||
const printer = parseInt(document.getElementById('modalPrinter').value);
|
const printer = parseInt(document.getElementById('modalPrinter').value);
|
||||||
const start = document.getElementById('modalStart').value;
|
const start = document.getElementById('modalStart').value;
|
||||||
const res = await fetch('/api/slots/new', {
|
// Clear previous error
|
||||||
|
document.getElementById('modalError').classList.add('d-none');
|
||||||
|
const r = await fetch('/api/slots/new', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start})
|
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start})
|
||||||
}).then(r => r.json());
|
});
|
||||||
|
const res = await r.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert('Erreur : ' + (res.error || 'inconnu'));
|
const errEl = document.getElementById('modalError');
|
||||||
|
errEl.querySelector('span').textContent = res.error || 'Erreur inconnue';
|
||||||
|
errEl.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user