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
+3
View File
@@ -87,6 +87,9 @@
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
<i class="bi bi-bookmark-star"></i> Templates
</a>
<a href="{{ url_for('planning') }}" class="{% if request.endpoint in ['planning','printers_page','working_schedule','calendar_blocks'] %}active{% endif %}">
<i class="bi bi-calendar3"></i> Planning
</a>
<div class="nav-section">Analyse</div>
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}">
<i class="bi bi-bar-chart-line"></i> Statistiques
+91
View File
@@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block title %}Indisponibilités{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-calendar-x me-2 text-secondary"></i>Indisponibilités</h1>
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Planning
</a>
</div>
<div class="row g-4">
<div class="col-md-5">
<div class="card">
<div class="card-header py-3">Ajouter une exception</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label fw-semibold">Date</label>
<input type="date" name="date" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Type</label>
<select name="type" class="form-select">
<option value="off">Off / Jour chômé</option>
<option value="tt">Télétravail (remplace onsite)</option>
<option value="onsite">Sur site (remplace TT)</option>
<option value="maintenance">Maintenance machine</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Imprimante concernée</label>
<select name="printer_id" class="form-select">
<option value="">Toutes les imprimantes</option>
{% for p in printers %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Note</label>
<input type="text" name="notes" class="form-control" placeholder="ex: Vacances août, maintenance buse…">
</div>
<button type="submit" class="btn fw-bold" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Ajouter
</button>
</form>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card">
<div class="card-header py-3">Exceptions récentes / à venir</div>
<div class="card-body p-0">
{% if blocks %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Type</th><th>Machine</th><th>Note</th><th></th></tr>
</thead>
<tbody>
{% for b in blocks %}
<tr>
<td class="fw-semibold">{{ b.date }}</td>
<td>
{% if b.type == 'off' %}<span class="badge bg-danger">Off</span>
{% elif b.type == 'tt' %}<span class="badge bg-primary">TT</span>
{% elif b.type == 'onsite' %}<span class="badge bg-warning text-dark">Sur site</span>
{% else %}<span class="badge bg-secondary">{{ b.type }}</span>{% endif %}
</td>
<td class="text-muted small">{{ b.printer_name or 'Toutes' }}</td>
<td class="text-muted small">{{ b.notes or '—' }}</td>
<td>
<form method="POST" action="{{ url_for('delete_calendar_block', id=b.id) }}">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-calendar-check fs-2"></i>
<p class="mt-2 small">Aucune exception définie.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+61 -4
View File
@@ -71,7 +71,7 @@
<div id="stockInfo" class="form-text"></div>
</div>
</div>
<div class="row g-3">
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label fw-semibold">Poids filament (g)</label>
<input type="number" name="weight_g" id="weight_g" class="form-control"
@@ -88,6 +88,20 @@
<div class="form-text">Temps total Bambu</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Pièces par plateau</label>
<input type="number" name="pieces_per_plate" id="pieces_per_plate" class="form-control"
min="1" step="1" value="1">
<div class="form-text">Combien de pièces tiennent sur un plateau.</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Quantité commandée</label>
<input type="number" name="order_qty" id="order_qty" class="form-control"
min="1" step="1" value="1">
<div class="form-text">Nombre total de pièces à produire.</div>
</div>
</div>
</div>
</div>
@@ -338,7 +352,7 @@ document.getElementById('materialSelect').addEventListener('change', function()
});
// ── Autres champs → recalc ────────────────────────────────────────────────────
['weight_g','hours','minutes','discount_pct'].forEach(id => {
['weight_g','hours','minutes','discount_pct','pieces_per_plate','order_qty'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', recalc);
});
@@ -366,6 +380,8 @@ function recalc() {
machine_profile_id: document.getElementById('machineProfileSelect').value || null,
handling_profile_id: document.getElementById('handlingProfileSelect').value || null,
material_profile_id: document.getElementById('materialProfileSelect').value || null,
pieces_per_plate: parseInt(document.getElementById('pieces_per_plate').value) || 1,
order_qty: parseInt(document.getElementById('order_qty').value) || 1,
};
fetch('/api/calculate', {
@@ -407,7 +423,47 @@ function renderBreakdown(d, body) {
const disRow = body.discount_pct > 0
? `<div class="breakdown-row text-danger"><span class="ps-2">Remise (${body.discount_pct}%)</span><span>-${fmt2(d.prix_ttc * body.discount_pct / 100)}</span></div>` : '';
document.getElementById('breakdown').innerHTML = `
// Multi-piece header
const qty = body.order_qty || 1;
const ppp = body.pieces_per_plate || 1;
const nbPlateaux = Math.ceil(qty / ppp);
const lastPlate = qty % ppp;
const priceOrder = d.price_order || (d.final_price * qty);
let plateauAlert = '';
if (lastPlate !== 0) {
plateauAlert = `<div class="alert alert-warning py-2 mb-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>
Dernier plateau incomplet : <strong>${lastPlate}/${ppp} pièces</strong>.
Vous pourriez imprimer jusqu'à <strong>${nbPlateaux * ppp}</strong> pièces pour le même coût fixe.
</div>`;
}
// Prix header (1 ou 2 colonnes)
const multiPriceHeader = qty > 1
? `<div class="d-flex justify-content-between align-items-center mb-3">
<div class="text-center flex-fill border-end">
<div class="small text-muted">Prix / pièce</div>
<div class="fw-bold fs-5" style="color:#ff6b35">${fmt2(d.final_price)}</div>
</div>
<div class="text-center flex-fill">
<div class="small text-muted">Prix commande (×${qty})</div>
<div class="fw-bold fs-5" style="color:#ff6b35">${fmt2(priceOrder)}</div>
</div>
</div>`
: '';
// Capacité
const cap = d.capacity || {};
const totalMin = d.total_print_min || 0;
const capBlock = `
<div class="breakdown-row section-header mt-1"><span>Capacite de production</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Nb plateaux necessaires</span><span><strong>${nbPlateaux}</strong> plateau${nbPlateaux>1?'x':''}</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Duree totale (impression + cooldown)</span><span>${Math.floor(totalMin/60)}h${String(Math.round(totalMin%60)).padStart(2,'0')}min</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / jour (${cap.effective_printers||5} machines)</span><span>${cap.pieces_per_day||'—'}</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / semaine</span><span>${cap.pieces_per_week||'—'}</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / mois (30j)</span><span>${cap.pieces_per_month||'—'}</span></div>`;
document.getElementById('breakdown').innerHTML = plateauAlert + multiPriceHeader + `
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Matiere <small class="text-secondary">${matName}</small></span><span>${fmt(d.material_cost)}</span></div>
<div class="breakdown-row"><span class="breakdown-muted ps-2">Marge matiere (${d._material_margin_pct}%)</span><span>${fmt(d.material_margin)}</span></div>
@@ -430,7 +486,8 @@ function renderBreakdown(d, body) {
<div class="breakdown-row total" style="border-top:none"><span>Prix HT</span><span class="final-price">${fmt2(d.prix_ht_net)}</span></div>
<div class="breakdown-row ${tvaClass}"><span class="ps-2">${tvaLabel}</span><span>${d._tva_pct > 0 ? fmt2(d.tva_amount) : ''}</span></div>
${disRow}
<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(d.final_price)}</span></div>`;
<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(d.final_price)}</span></div>
` + capBlock;
}
// Init
+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 %}
+83
View File
@@ -0,0 +1,83 @@
{% extends 'base.html' %}
{% block title %}Imprimantes{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-printer me-2 text-secondary"></i>Imprimantes</h1>
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Planning
</a>
</div>
<div class="row g-4">
<div class="col-md-7">
<div class="card">
<div class="card-header py-3">Parc machines</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Nom</th><th>Statut</th><th>Notes</th><th></th></tr>
</thead>
<tbody>
{% for p in printers %}
<tr>
<td class="fw-semibold">{{ p.name }}</td>
<td>
{% if p.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-muted small">{{ p.notes or '—' }}</td>
<td>
<div class="d-flex gap-1">
<form method="POST" action="{{ url_for('toggle_printer', id=p.id) }}">
<button class="btn btn-sm {% if p.is_active %}btn-outline-warning{% else %}btn-outline-success{% endif %} py-0">
{% if p.is_active %}<i class="bi bi-pause"></i>{% else %}<i class="bi bi-play"></i>{% endif %}
</button>
</form>
<form method="POST" action="{{ url_for('delete_printer', id=p.id) }}"
onsubmit="return confirm('Supprimer {{ p.name }} ?')">
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-5">
<div class="card">
<div class="card-header py-3">Ajouter une imprimante</div>
<div class="card-body">
<form method="POST">
<div class="mb-3">
<label class="form-label fw-semibold">Nom</label>
<input type="text" name="name" class="form-control" required placeholder="ex: A1 #7, X1C #1">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<input type="text" name="notes" class="form-control" placeholder="couleur, emplacement…">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="is_active" id="isActive" checked>
<label class="form-check-label" for="isActive">Active dès l'ajout</label>
</div>
<button type="submit" class="btn fw-bold" style="background:#ff6b35;color:#fff">
<i class="bi bi-plus-lg me-1"></i>Ajouter
</button>
</form>
</div>
</div>
<div class="alert alert-info mt-3 small">
<i class="bi bi-info-circle me-1"></i>
Max <strong>5 imprimantes simultanées</strong> (contrainte électrique). Configurez ce maximum dans <a href="{{ url_for('settings') }}">Paramètres</a>.
<br>L'imprimante #6 est inactive par défaut.
</div>
</div>
</div>
{% endblock %}
+24
View File
@@ -109,6 +109,30 @@
</div>
</div>
<div class="card mb-4">
<div class="card-header py-3"><i class="bi bi-calendar3 me-2"></i>Planning & capacite</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">Cooldown entre impressions (minutes)</label>
<input type="number" name="cooldown_minutes" class="form-control"
step="1" min="0" value="{{ settings.get('cooldown_minutes', 15) }}">
<div class="form-text">Temps de refroidissement + dechargement entre deux lancements sur la meme machine.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Buffer echec filament (%)</label>
<input type="number" name="failure_buffer_pct" class="form-control"
step="1" min="0" max="50" value="{{ settings.get('failure_buffer_pct', 10) }}">
<div class="form-text">Surconsommation estimee due aux echecs d'impression (alerte restock).</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Nb max imprimantes simultanees</label>
<input type="number" name="max_simultaneous_printers" class="form-control"
step="1" min="1" max="10" value="{{ settings.get('max_simultaneous_printers', 5) }}">
<div class="form-text"><strong>5</strong> — contrainte electrique (6 × A1 = trop de W au-dela de 5).</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header py-3"><i class="bi bi-percent me-2"></i>Fiscal</div>
<div class="card-body">
+97
View File
@@ -0,0 +1,97 @@
{% extends 'base.html' %}
{% block title %}Horaires{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h1><i class="bi bi-clock me-2 text-secondary"></i>Horaires d'impression</h1>
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Planning
</a>
</div>
<div class="row">
<div class="col-lg-9">
<form method="POST">
<div class="card">
<div class="card-header py-3">Planning hebdomadaire par défaut</div>
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:120px">Jour</th>
<th style="width:150px">Mode</th>
<th>Fenêtre TT (début → fin)</th>
<th>Créneaux sur site</th>
</tr>
</thead>
<tbody>
{% set day_names = ['Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi','Dimanche'] %}
{% for s in schedule %}
<tr>
<td class="fw-semibold">{{ day_names[s.day_of_week] }}</td>
<td>
<select name="mode_{{ s.day_of_week }}" class="form-select form-select-sm mode-select"
data-dow="{{ s.day_of_week }}" onchange="toggleRow(this)">
<option value="tt" {% if s.mode=='tt' %}selected{% endif %}>Télétravail</option>
<option value="onsite" {% if s.mode=='onsite' %}selected{% endif %}>Sur site</option>
<option value="off" {% if s.mode=='off' %}selected{% endif %}>Off</option>
</select>
</td>
<td class="tt-fields-{{ s.day_of_week }}" {% if s.mode != 'tt' %}style="opacity:.3;pointer-events:none"{% endif %}>
<div class="d-flex gap-2 align-items-center">
<input type="time" name="tt_start_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
value="{{ s.tt_window_start }}">
<span class="text-muted"></span>
<input type="time" name="tt_end_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
value="{{ s.tt_window_end }}">
</div>
</td>
<td class="onsite-fields-{{ s.day_of_week }}" {% if s.mode != 'onsite' %}style="opacity:.3;pointer-events:none"{% endif %}>
<div class="d-flex gap-2 align-items-center">
<input type="time" name="onsite_slot1_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
value="{{ s.onsite_slot_1 }}">
<span class="text-muted">+</span>
<input type="time" name="onsite_slot2_{{ s.day_of_week }}" class="form-control form-control-sm" style="width:110px"
value="{{ s.onsite_slot_2 }}">
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn px-4 fw-bold" style="background:#ff6b35;color:#fff">
<i class="bi bi-save me-2"></i>Enregistrer
</button>
</div>
</form>
</div>
<div class="col-lg-3">
<div class="card">
<div class="card-header py-3">Légende</div>
<div class="card-body small">
<p><span class="badge bg-primary">Télétravail</span><br>Fenêtre continue — les impressions se lancent en séquence de l'heure de début à l'heure de fin.</p>
<p><span class="badge bg-warning text-dark">Sur site</span><br>2 créneaux fixes — matin (avant de partir) et soir (en rentrant).</p>
<p><span class="badge bg-secondary">Off</span><br>Aucune impression ce jour-là par défaut.</p>
<hr>
<p class="text-muted">Ces horaires sont remplacés jour par jour via les <a href="{{ url_for('calendar_blocks') }}">Indisponibilités</a>.</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleRow(sel) {
const dow = sel.dataset.dow;
const mode = sel.value;
const ttEl = document.querySelector('.tt-fields-' + dow);
const onsiteEl = document.querySelector('.onsite-fields-' + dow);
ttEl.style.opacity = mode === 'tt' ? '1' : '0.3';
ttEl.style.pointerEvents = mode === 'tt' ? '' : 'none';
onsiteEl.style.opacity = mode === 'onsite' ? '1' : '0.3';
onsiteEl.style.pointerEvents = mode === 'onsite' ? '' : 'none';
}
</script>
{% endblock %}