Planning complet : multi-pièces, capacité, Gantt, iCal, horaires, imprimantes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,'"')})">
|
||||
<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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user