761 lines
32 KiB
HTML
761 lines
32 KiB
HTML
{% 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,'"')})">
|
||
<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 %}
|