feat: max simultaneous printers enforced + capacity bands on Gantt
Deploy via Portainer / deploy (push) Successful in 0s

This commit is contained in:
Jo
2026-07-07 10:39:50 +02:00
parent 00518c3f3c
commit 10fdeb3ebc
5 changed files with 269 additions and 26 deletions
+121 -24
View File
@@ -126,6 +126,9 @@
<div id="modalSuggestion" class="alert alert-success py-2 small d-none">
<i class="bi bi-magic me-1"></i><span id="modalSuggestionText"></span>
</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 class="modal-footer">
<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;
/**
* 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() {
const [slotsRes, availRes] = await Promise.all([
fetch('/api/slots').then(r => r.json()),
@@ -239,17 +294,33 @@ async function initTimeline() {
}));
// Print slots
const slotItems = slotsRes.map(s => ({
id: s.id,
group: s.printer_id,
start: s.planned_start,
end: s.planned_end,
content: `<span style="font-size:.7rem">${s.job_name}</span>`,
title: `${s.job_name}${s.client_name ? ' — ' + s.client_name : ''}<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`,
}));
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,
group: s.printer_id,
start: s.planned_start,
end: s.planned_end,
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: `<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`,
};
});
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();
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>';
return;
}
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 15);
el.innerHTML = `<table class="table table-sm table-hover mb-0">
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 20);
el.innerHTML = `<div class="table-responsive"><table class="table table-sm table-hover mb-0">
<thead class="table-light"><tr>
<th class="small">Début</th><th class="small">Job</th>
<th class="small">Machine</th><th class="small">Statut</th>
<th class="small">Début</th>
<th class="small">Job</th>
<th class="small">Durée</th>
<th class="small">Machine</th>
<th class="small">Statut</th>
</tr></thead>
<tbody>
${upcoming.map(s => `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'&quot;')})">
<td class="small">${formatDt(s.planned_start)}</td>
<td class="small fw-semibold">${s.job_name}${s.client_name ? '<br><span class=\'text-muted fw-normal\'>' + s.client_name + '</span>' : ''}</td>
<td class="small">${s.printer_name}</td>
<td><span class="badge" style="background:${STATUS_COLORS[s.status]}">${STATUS_LABELS[s.status]||s.status}</span></td>
</tr>`).join('')}
</tbody></table>`;
${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
? `<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,'&quot;')})">
<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>
</tr>`;
}).join('')}
</tbody></table></div>`;
}
function formatDt(iso) {
@@ -350,16 +442,21 @@ async function saveSlot() {
const jobId = parseInt(document.getElementById('modalJobId').value);
const printer = parseInt(document.getElementById('modalPrinter').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',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start})
}).then(r => r.json());
});
const res = await r.json();
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
location.reload();
} else {
alert('Erreur : ' + (res.error || 'inconnu'));
const errEl = document.getElementById('modalError');
errEl.querySelector('span').textContent = res.error || 'Erreur inconnue';
errEl.classList.remove('d-none');
}
}