Files
Jo 1899165d7f
Deploy via Portainer / deploy (push) Successful in 0s
fix: gantt starts 12h before now to show running slots
2026-07-07 13:21:03 +02:00

761 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends 'base.html' %}
{% block title %}Planning{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<h1><i class="bi bi-calendar3 me-2" style="color:#ff6b35"></i>Planning d'impression</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('working_schedule') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-clock me-1"></i>Horaires
</a>
<a href="{{ url_for('calendar_blocks') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-calendar-x me-1"></i>Indispos
</a>
<a href="{{ url_for('printers_page') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-printer me-1"></i>Imprimantes
</a>
<a href="/calendar.ics" class="btn btn-sm btn-outline-info">
<i class="bi bi-calendar-check me-1"></i>Export iCal
</a>
</div>
</div>
<div class="row g-3">
<!-- Timeline -->
<div class="col-12">
<div class="card">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-bar-chart-gantt me-2"></i>Gantt — 14 prochains jours</span>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-secondary" onclick="shiftTimeline(-7)"><i class="bi bi-chevron-left"></i></button>
<button class="btn btn-sm btn-outline-secondary" onclick="timeline.moveTo(new Date())">Aujourd'hui</button>
<button class="btn btn-sm btn-outline-secondary" onclick="shiftTimeline(7)"><i class="bi bi-chevron-right"></i></button>
</div>
</div>
<div class="card-body p-0">
<div id="timeline" style="height:320px"></div>
</div>
</div>
</div>
<!-- Commandes non planifiées -->
<div class="col-md-5">
<div class="card">
<div class="card-header py-3">
<i class="bi bi-inbox me-2"></i>À planifier
<span class="badge bg-secondary ms-1">{{ unscheduled|length }}</span>
</div>
<div class="card-body p-0">
{% if unscheduled %}
<div class="list-group list-group-flush">
{% for j in unscheduled %}
{% set missing = j.total_plates_needed - j.scheduled_plates %}
<div class="list-group-item py-2">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="fw-semibold small">{{ j.name }}</div>
<div class="text-muted" style="font-size:.75rem">
{{ j.print_time_s | fmt_time }}
{% if j.order_qty > 1 %} · ×{{ j.order_qty }} pièces{% endif %}
{% if j.client_name %} · {{ j.client_name }}{% endif %}
</div>
{% if j.total_plates_needed > 1 %}
<div class="mt-1">
<span class="badge bg-warning text-dark" style="font-size:.65rem">
<i class="bi bi-layers me-1"></i>
Plateau {{ j.scheduled_plates + 1 }}/{{ j.total_plates_needed }}
{% if missing > 1 %} · {{ missing }} restants{% endif %}
</span>
</div>
{% endif %}
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-success py-0"
onclick="suggestSlot({{ j.id }}, {{ j.print_time_s }}, '{{ j.name|replace("'","\\'")|truncate(30,true,"…") }}')">
<i class="bi bi-magic"></i>
</button>
<button class="btn btn-sm btn-outline-primary py-0"
onclick="openScheduleModal({{ j.id }}, {{ j.print_time_s }}, '{{ j.name|replace("'","\\'")|truncate(30,true,"…") }}')">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-4 small">
<i class="bi bi-check2-circle fs-3"></i>
<p class="mt-2">Toutes les commandes sont planifiées.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Prochains créneaux -->
<div class="col-md-7">
<div class="card">
<div class="card-header py-3"><i class="bi bi-list-ul me-2"></i>Créneaux planifiés</div>
<div class="card-body p-0" id="slotsList">
<div class="text-center text-muted py-3 small">Chargement…</div>
</div>
</div>
</div>
<!-- Live HA Status -->
<div class="col-md-5">
<div class="card">
<div class="card-header py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-activity me-2 text-success"></i>Imprimantes en direct</span>
<span id="haLastUpdate" class="text-muted" style="font-size:.7rem"></span>
</div>
<div class="card-body p-2" id="haPrinterCards">
<div class="text-center text-muted py-3 small">
<i class="bi bi-hourglass-split me-1"></i>Chargement…
</div>
</div>
</div>
</div>
</div>
<!-- Modal planification -->
<div class="modal fade" id="scheduleModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-calendar-plus me-2"></i>Planifier l'impression</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="modalJobId">
<input type="hidden" id="modalPrintTime">
<div class="mb-3">
<label class="form-label fw-semibold">Commande</label>
<div id="modalJobName" class="form-control-plaintext fw-semibold text-primary"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Imprimante</label>
<select id="modalPrinter" class="form-select">
{% for p in printers %}
{% if p.is_active %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Date et heure de lancement</label>
<input type="datetime-local" id="modalStart" class="form-control">
</div>
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="modalAlreadyDone" onchange="onAlreadyDoneChange()">
<label class="form-check-label fw-semibold text-success" for="modalAlreadyDone">
<i class="bi bi-check-circle-fill me-1"></i>Déjà imprimé
</label>
</div>
<div class="form-text ms-4">Enregistre le plateau comme terminé sans passer par le planning actif.</div>
</div>
<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>
<button type="button" id="modalSaveBtn" class="btn fw-bold" style="background:#ff6b35;color:#fff" onclick="saveSlot()">
<i class="bi bi-save me-1"></i>Planifier
</button>
</div>
</div>
</div>
</div>
<!-- Modal statut slot -->
<div class="modal fade" id="slotModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Mettre à jour le créneau</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="slotModalId">
<p id="slotModalInfo" class="text-muted small"></p>
<!-- Quick action buttons -->
<div class="d-flex gap-2 mb-3">
<button class="btn btn-sm btn-success flex-fill" onclick="quickAction('done')">
<i class="bi bi-check-circle me-1"></i>Terminé
</button>
<button class="btn btn-sm btn-danger flex-fill" onclick="quickAction('fail')">
<i class="bi bi-exclamation-triangle me-1"></i>Échec
</button>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Statut</label>
<select id="slotModalStatus" class="form-select" onchange="onStatusChange()">
<option value="planned">Planifié</option>
<option value="printing">En cours</option>
<option value="done">Terminé ✓</option>
<option value="failed">Échec ✗</option>
<option value="cancelled">Annulé</option>
</select>
</div>
<div class="mb-3" id="slotModalNoteGroup">
<label class="form-label">Note d'échec (optionnel)</label>
<input type="text" id="slotModalNote" class="form-control" placeholder="Ex: couche 45 — sous-extrusion">
</div>
<div class="mb-3" id="slotModalWastedGroup" style="display:none">
<label class="form-label">Filament gaspillé (g) <small class="text-muted fw-normal">optionnel</small></label>
<input type="number" id="slotModalWasted" class="form-control" step="0.1" min="0"
placeholder="Poids plateau si vide">
</div>
<div class="mb-3" id="slotModalActualGroup" style="display:none">
<label class="form-label">Poids réel consommé (g) <small class="text-muted fw-normal">optionnel</small></label>
<input type="number" id="slotModalActual" class="form-control" step="0.1" min="0"
placeholder="Pour déduire du stock">
</div>
</div>
<div class="modal-footer d-flex justify-content-between">
<button class="btn btn-sm btn-outline-danger" onclick="deleteSlot()">
<i class="bi bi-trash"></i> Supprimer
</button>
<div>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-success ms-1" onclick="updateSlotStatus()">Enregistrer</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>
<script>
const HA_POLL_MS = {{ (settings.get('ha_poll_interval', '60') | int) * 1000 }};
const STATUS_COLORS = {
planned: '#3b82f6',
printing: '#f59e0b',
done: '#22c55e',
failed: '#ef4444',
cancelled: '#9ca3af',
};
const STATUS_LABELS = {
planned: 'Planifié', printing: 'En cours', done: 'Terminé', failed: 'Échec', cancelled: 'Annulé'
};
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()),
fetch('/api/availability').then(r => r.json()),
]);
// Printers as groups
const printerData = {{ printers | tojson }};
groups = new vis.DataSet(printerData.map(p => ({
id: p.id,
content: `<span class="small fw-semibold">${p.name}</span>`,
className: p.is_active ? '' : 'text-muted'
})));
// Background items (unavailable periods)
const bgItems = availRes.map((a, i) => ({
id: 'bg_' + i,
start: a.start,
end: a.end,
type: 'background',
className: a.type === 'off' ? 'bg-danger bg-opacity-10' : 'bg-secondary bg-opacity-10',
}));
// Print slots
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`,
};
});
// 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(
document.getElementById('timeline'),
items, groups,
{
start: new Date(now.getTime() - 12 * 3600000),
end: new Date(now.getTime() + 14 * 86400000),
groupOrder: 'id',
selectable: true,
zoomMin: 3600000,
zoomMax: 60 * 86400000,
locale: 'fr',
}
);
timeline.on('select', function(props) {
if (!props.items.length) return;
const id = props.items[0];
if (typeof id === 'string' && id.startsWith('bg_')) return;
const slot = slotsRes.find(s => s.id === id);
if (!slot) return;
openSlotModal(slot);
});
renderSlotsList(slotsRes);
}
function shiftTimeline(days) {
const w = timeline.getWindow();
const ms = days * 86400000;
timeline.setWindow(new Date(w.start.getTime() + ms), new Date(w.end.getTime() + ms));
}
function renderSlotsList(slots) {
const el = document.getElementById('slotsList');
if (!slots.length) {
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, 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
? `<span class="badge ms-2" style="background:#fbbf24;color:#78350f;font-size:.65rem">
<i class="bi bi-exclamation-triangle-fill me-1"></i>${remaining} pièce(s) à compenser
</span>`
: (deficit > 0
? `<span class="badge bg-success ms-2" style="font-size:.65rem"><i class="bi bi-check-circle-fill me-1"></i>Compensé</span>`
: '');
rows += `<tr style="background:#eef2ff;border-top:2px solid #c7d2fe">
<td colspan="5" style="padding:.3rem .75rem">
<span style="font-size:.78rem;font-weight:600;color:#3730a3">
<i class="bi bi-layers-half me-1"></i>${s.job_name}${job.totalPlates} plateaux
</span>${defBadge}
</td>
</tr>`;
}
// ── 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
? `<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>`
: '';
// Plate label: for multi-plate show "Plateau X/Y", else fallback to plate_name
const platePart = isMulti
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>Plateau ${s.plate_num}/${s.total_plates}</span>`
: (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>`
: '';
// Pieces delivered on this slot (if already printed with ignores)
const ignoredPart = s.pieces_ignored > 0
? `<br><span style="font-size:.7rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px">
<i class="bi bi-exclamation-triangle-fill me-1"></i>${s.effective_pieces - s.pieces_ignored}/${s.effective_pieces} pièces livrées
</span>`
: '';
// Override badge (slot was manually adjusted)
const overridePart = s.pieces_override !== null
? `<br><span style="font-size:.7rem;background:#dcfce7;color:#166534;padding:1px 5px;border-radius:3px">
<i class="bi bi-check-circle-fill me-1"></i>${s.pieces_override} pièces (ajusté)
</span>`
: '';
// 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)
? `<br><span style="font-size:.7rem;background:#fff7ed;color:#9a3412;padding:1px 6px;border-radius:3px;cursor:pointer"
onclick="event.stopPropagation();applyCompensation(${s.id}, ${s.effective_pieces}, ${stillNeeded})">
<i class="bi bi-arrow-up-circle-fill me-1"></i>Compenser : imprimer ${s.effective_pieces + stillNeeded} ✏️
</span>`
: '';
// 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 += `<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">${jobLabel}${platePart}${filPart}${clientPart}${ignoredPart}${overridePart}${compensatePart}</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>`;
}
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 / Plateau</th>
<th class="small">Durée</th>
<th class="small">Machine</th>
<th class="small">Statut</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`;
}
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) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit'}) + ' ' +
d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
}
function onAlreadyDoneChange() {
const done = document.getElementById('modalAlreadyDone').checked;
const btn = document.getElementById('modalSaveBtn');
if (done) {
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Enregistrer comme terminé';
btn.style.background = '#22c55e';
// Reset to "now" so user picks when it was done
const now = new Date();
now.setMinutes(0, 0, 0);
document.getElementById('modalStart').value = now.toISOString().slice(0,16);
document.getElementById('modalSuggestion').classList.add('d-none');
document.getElementById('modalError').classList.add('d-none');
} else {
btn.innerHTML = '<i class="bi bi-save me-1"></i>Planifier';
btn.style.background = '#ff6b35';
}
}
function openScheduleModal(jobId, printTime, jobName) {
document.getElementById('modalJobId').value = jobId;
document.getElementById('modalPrintTime').value = printTime;
document.getElementById('modalJobName').textContent = jobName;
document.getElementById('modalSuggestion').classList.add('d-none');
document.getElementById('modalError').classList.add('d-none');
// Reset already-done checkbox
document.getElementById('modalAlreadyDone').checked = false;
onAlreadyDoneChange();
// Default: now rounded to next hour
const now = new Date();
now.setMinutes(0, 0, 0);
now.setHours(now.getHours() + 1);
document.getElementById('modalStart').value = now.toISOString().slice(0,16);
new bootstrap.Modal(document.getElementById('scheduleModal')).show();
}
async function suggestSlot(jobId, printTime, jobName) {
openScheduleModal(jobId, printTime, jobName);
const res = await fetch('/api/suggest-slot', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({print_time_s: printTime})
}).then(r => r.json());
if (res.planned_start) {
document.getElementById('modalStart').value = res.planned_start.slice(0,16);
// Find the printer option
const sel = document.getElementById('modalPrinter');
for (let opt of sel.options) {
if (parseInt(opt.value) === res.printer_id) { sel.value = opt.value; break; }
}
document.getElementById('modalSuggestionText').textContent =
`Suggestion : ${res.printer_name}${formatDt(res.planned_start)}`;
document.getElementById('modalSuggestion').classList.remove('d-none');
}
}
async function saveSlot() {
const jobId = parseInt(document.getElementById('modalJobId').value);
const printer = parseInt(document.getElementById('modalPrinter').value);
const start = document.getElementById('modalStart').value;
const alreadyDone = document.getElementById('modalAlreadyDone').checked;
// 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,
already_done: alreadyDone})
});
const res = await r.json();
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
location.reload();
} else {
const errEl = document.getElementById('modalError');
errEl.querySelector('span').textContent = res.error || 'Erreur inconnue';
errEl.classList.remove('d-none');
}
}
let currentSlot = null;
function onStatusChange() {
const status = document.getElementById('slotModalStatus').value;
document.getElementById('slotModalWastedGroup').style.display = status === 'failed' ? '' : 'none';
document.getElementById('slotModalActualGroup').style.display = status === 'done' ? '' : 'none';
}
function openSlotModal(slot) {
currentSlot = slot;
document.getElementById('slotModalId').value = slot.id;
document.getElementById('slotModalInfo').textContent =
`${slot.job_name} · ${slot.printer_name} · ${formatDt(slot.planned_start)}`;
document.getElementById('slotModalStatus').value = slot.status || 'planned';
document.getElementById('slotModalNote').value = slot.failure_note || '';
document.getElementById('slotModalWasted').value = '';
document.getElementById('slotModalActual').value = '';
onStatusChange();
new bootstrap.Modal(document.getElementById('slotModal')).show();
}
async function quickAction(type) {
// Submit immediately via the dedicated fail/done endpoints
const id = document.getElementById('slotModalId').value;
const note = document.getElementById('slotModalNote').value;
const wasted_g = document.getElementById('slotModalWasted').value || '';
const actual_g = document.getElementById('slotModalActual').value || '';
if (type === 'done') {
const fd = new FormData();
fd.append('actual_g', actual_g);
await fetch(`/api/slots/${id}/done`, {method:'POST', body:fd});
} else {
const fd = new FormData();
fd.append('failure_note', note);
fd.append('wasted_g', wasted_g);
await fetch(`/api/slots/${id}/fail`, {method:'POST', body:fd});
}
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
location.reload();
}
async function updateSlotStatus() {
const id = document.getElementById('slotModalId').value;
const status = document.getElementById('slotModalStatus').value;
const note = document.getElementById('slotModalNote').value;
const wasted = document.getElementById('slotModalWasted').value;
const actual = document.getElementById('slotModalActual').value;
if (status === 'done') {
const fd = new FormData();
fd.append('actual_g', actual);
await fetch(`/api/slots/${id}/done`, {method:'POST', body:fd});
} else if (status === 'failed') {
const fd = new FormData();
fd.append('failure_note', note);
fd.append('wasted_g', wasted);
await fetch(`/api/slots/${id}/fail`, {method:'POST', body:fd});
} else {
await fetch(`/api/slots/${id}/status`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({status, note})
});
}
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
location.reload();
}
async function deleteSlot() {
if (!confirm('Supprimer ce créneau ?')) return;
const id = document.getElementById('slotModalId').value;
await fetch(`/api/slots/${id}/delete`, {method:'POST'});
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
location.reload();
}
initTimeline();
// ── Home Assistant live status ──────────────────────────────────────────────
async function refreshHaStatus() {
try {
const r = await fetch('/api/ha/status');
const d = await r.json();
const el = document.getElementById('haPrinterCards');
const lu = document.getElementById('haLastUpdate');
lu.textContent = 'Mis à jour ' + new Date().toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit'});
if (d.error && !d.printers) {
el.innerHTML = `<div class="text-muted small p-3"><i class="bi bi-info-circle me-1"></i>${d.error}</div>`;
return;
}
const printers = d.printers || {};
const keys = Object.keys(printers);
if (!keys.length) {
el.innerHTML = '<div class="text-muted small p-3"><i class="bi bi-info-circle me-1"></i>Aucune imprimante avec préfixe HA. Configurez dans Paramètres + Imprimantes.</div>';
return;
}
const STATUS_LABEL = {running:'En cours',finish:'Terminé',idle:'Idle',pause:'En pause',failed:'Erreur',offline:'Hors ligne',init:'Démarrage',unknown:'—'};
const STATUS_CLS = {running:'success',finish:'secondary',idle:'light',pause:'warning',failed:'danger',offline:'dark',init:'info',unknown:'light'};
el.innerHTML = keys.map(pid => {
const p = printers[pid];
const sc = STATUS_CLS[p.status] || 'light';
const sl = STATUS_LABEL[p.status] || p.status;
const prog = p.progress || 0;
const barColor = p.error ? '#ef4444' : p.status === 'running' ? '#22c55e' : '#94a3b8';
const layerInfo = (p.layers_total > 0)
? `<span class="text-muted" style="font-size:.7rem">Couche ${p.layer}/${p.layers_total}</span>`
: '';
const finInfo = (p.heure_fin && p.heure_fin !== 'unknown' && p.status === 'running')
? `<span class="text-muted" style="font-size:.7rem">Fin ≈ ${new Date(p.heure_fin).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})}</span>`
: '';
const taskInfo = (p.task_name && p.task_name !== 'unknown')
? `<div class="text-truncate" style="font-size:.72rem;color:#6b7280;max-width:220px" title="${p.task_name}">${p.task_name}</div>`
: '';
const ignoredWarn = (p.pieces_ignored > 0)
? `<div class="mt-1"><span class="badge" style="background:#fef3c7;color:#92400e;font-size:.7rem">
<i class="bi bi-exclamation-triangle-fill me-1"></i>${p.pieces_total - p.pieces_ignored}/${p.pieces_total} pièces — ${p.pieces_ignored} ignorée(s)
</span></div>`
: '';
return `<div class="p-2 mb-1 rounded" style="background:#f8f9fa;border:1px solid ${p.pieces_ignored>0?'#fbbf24':'#e5e7eb'}">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-semibold small">${p.printer_name}</span>
<span class="badge bg-${sc} text-${sc==='light'?'dark':'white'}">${sl}</span>
</div>
${taskInfo}
${ignoredWarn}
<div class="d-flex justify-content-between align-items-center mt-1">
<div class="flex-fill me-2">
<div style="height:6px;background:#e5e7eb;border-radius:3px;overflow:hidden">
<div style="width:${prog}%;height:100%;background:${barColor};transition:width .5s"></div>
</div>
</div>
<span class="fw-bold small" style="min-width:36px;text-align:right">${prog}%</span>
</div>
<div class="d-flex gap-3 mt-1">${layerInfo}${finInfo}</div>
</div>`;
}).join('');
} catch(e) {
console.warn('HA status error:', e);
}
}
refreshHaStatus();
setInterval(refreshHaStatus, HA_POLL_MS);
</script>
{% endblock %}