Planning complet : multi-pièces, capacité, Gantt, iCal, horaires, imprimantes

This commit is contained in:
Jo
2026-07-06 16:15:14 +02:00
parent dbbbc92d91
commit bd6f244260
8 changed files with 1308 additions and 76 deletions
+378
View File
@@ -0,0 +1,378 @@
{% 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 %}
<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>
</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>
</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 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>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" 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>
<div class="mb-3">
<label class="form-label fw-semibold">Statut</label>
<select id="slotModalStatus" class="form-select">
<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 (optionnel)</label>
<input type="text" id="slotModalNote" class="form-control" placeholder="Ex: couche 45 — sous-extrusion">
</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 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;
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 => ({
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`,
}));
items = new vis.DataSet([...bgItems, ...slotItems]);
const now = new Date();
timeline = new vis.Timeline(
document.getElementById('timeline'),
items, groups,
{
start: now,
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, 15);
el.innerHTML = `<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>
</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>`;
}
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 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');
// 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 res = 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());
if (res.ok) {
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
location.reload();
} else {
alert('Erreur : ' + (res.error || 'inconnu'));
}
}
let currentSlot = null;
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 || '';
new bootstrap.Modal(document.getElementById('slotModal')).show();
}
async function updateSlotStatus() {
const id = document.getElementById('slotModalId').value;
const status = document.getElementById('slotModalStatus').value;
const note = document.getElementById('slotModalNote').value;
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();
</script>
{% endblock %}