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:
+121
-24
@@ -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,'"')})">
|
||||
<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,'"')})">
|
||||
<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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user