379 lines
15 KiB
HTML
379 lines
15 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 %}
|
||
<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,'"')})">
|
||
<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 %}
|