Files
3d-pricing/templates/planning.html
T

379 lines
15 KiB
HTML
Raw 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 %}
<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 %}