Compare commits
32 Commits
0c9e04b6e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3da19f55a5 | |||
| 0069ab2592 | |||
| 80307aca7f | |||
| b4debbd6a0 | |||
| f51a78940f | |||
| 522a0c5e83 | |||
| b55edceff8 | |||
| 7824124337 | |||
| 1899165d7f | |||
| 56d80521d7 | |||
| 1b740f9cee | |||
| b13d718d92 | |||
| 221a0d7b8c | |||
| 95b8b0277b | |||
| 29af4b4917 | |||
| de9ecec6dc | |||
| 935a2f9634 | |||
| 5686706a4e | |||
| 4206a8934e | |||
| 10fdeb3ebc | |||
| 00518c3f3c | |||
| b571a5ef0c | |||
| d160686e0c | |||
| b6fc0565ee | |||
| b22e5e0fb4 | |||
| 7b65c4b2e2 | |||
| e410bfbc16 | |||
| 6ee16797db | |||
| d6c53479cf | |||
| ddd0f6bee5 | |||
| 9cb7c71d6f | |||
| c8d209e02c |
Binary file not shown.
@@ -9,6 +9,14 @@ services:
|
||||
- 3d-pricing-data:/data
|
||||
environment:
|
||||
- DATABASE_PATH=/data/pricing.db
|
||||
- SECRET_KEY=${SECRET_KEY:-changeme_generate_with_openssl_rand_hex_32}
|
||||
# ── OIDC Authelia (laisser vide pour désactiver) ──────────────────
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
|
||||
# URL de fin de session Authelia (optionnel)
|
||||
# ex: https://auth.tondomain.com/api/oidc/end-session
|
||||
- OIDC_END_SESSION_URL=${OIDC_END_SESSION_URL:-}
|
||||
|
||||
volumes:
|
||||
3d-pricing-data:
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
flask>=3.0.0
|
||||
authlib>=1.3.0
|
||||
requests>=2.31.0
|
||||
|
||||
+224
-25
@@ -7,35 +7,79 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
:root { --sidebar-w: 220px; }
|
||||
:root {
|
||||
--sidebar-w: 220px;
|
||||
--sidebar-collapsed-w: 52px;
|
||||
--sidebar-transition: width .2s ease, margin-left .2s ease;
|
||||
}
|
||||
body { background: #f4f6f9; }
|
||||
|
||||
#sidebar {
|
||||
width: var(--sidebar-w); min-height: 100vh;
|
||||
background: #1a1d23; position: fixed; top: 0; left: 0;
|
||||
display: flex; flex-direction: column;
|
||||
transition: width .2s ease;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
#sidebar.collapsed { width: var(--sidebar-collapsed-w); }
|
||||
|
||||
#sidebar .brand {
|
||||
padding: 1.25rem 1.25rem 1rem; color: #fff;
|
||||
padding: 1rem 1.25rem; color: #fff;
|
||||
font-weight: 700; font-size: 1.1rem;
|
||||
border-bottom: 1px solid #2e323d;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
white-space: nowrap; position: relative;
|
||||
}
|
||||
#sidebar .brand .brand-text { transition: opacity .15s; }
|
||||
#sidebar.collapsed .brand .brand-text { opacity: 0; pointer-events: none; }
|
||||
#sidebar .brand span { color: #ff6b35; }
|
||||
#sidebar nav { flex: 1; padding: .5rem 0; }
|
||||
|
||||
#sidebarToggle {
|
||||
background: none; border: none; color: #9ba3af; cursor: pointer;
|
||||
padding: .2rem .3rem; border-radius: 4px; flex-shrink: 0;
|
||||
transition: color .15s, background .15s, right .2s ease;
|
||||
font-size: 1rem; line-height: 1;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
#sidebarToggle:hover { color: #fff; background: #2e323d; }
|
||||
/* En mode replié, centrer le bouton dans les 52px */
|
||||
#sidebar.collapsed #sidebarToggle {
|
||||
position: absolute;
|
||||
left: 50%; transform: translateX(-50%);
|
||||
}
|
||||
|
||||
#sidebar nav { flex: 1; padding: .5rem 0; overflow: hidden; }
|
||||
#sidebar nav a {
|
||||
display: flex; align-items: center; gap: .65rem;
|
||||
padding: .6rem 1.25rem; color: #9ba3af;
|
||||
text-decoration: none; font-size: .875rem;
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#sidebar nav a:hover, #sidebar nav a.active { background: #2e323d; color: #fff; }
|
||||
#sidebar nav a.active { border-left: 3px solid #ff6b35; }
|
||||
#sidebar nav a i { flex-shrink: 0; font-size: 1rem; width: 1.1rem; text-align: center; }
|
||||
#sidebar nav .nav-label { transition: opacity .15s; }
|
||||
#sidebar.collapsed nav .nav-label { opacity: 0; }
|
||||
#sidebar nav .nav-section {
|
||||
padding: .75rem 1.25rem .25rem; font-size: .7rem;
|
||||
text-transform: uppercase; letter-spacing: .08em; color: #4b5563;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
#sidebar.collapsed nav .nav-section { opacity: 0; }
|
||||
|
||||
#sidebar .sidebar-user { transition: opacity .15s; }
|
||||
#sidebar.collapsed .sidebar-user { opacity: 0; pointer-events: none; }
|
||||
|
||||
#main {
|
||||
margin-left: var(--sidebar-w);
|
||||
padding: 1.75rem 2rem;
|
||||
transition: margin-left .2s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
#main.sidebar-collapsed { margin-left: var(--sidebar-collapsed-w); }
|
||||
|
||||
#main { margin-left: var(--sidebar-w); padding: 1.75rem 2rem; }
|
||||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-header h1 { font-size: 1.4rem; font-weight: 700; margin: 0; }
|
||||
|
||||
@@ -61,47 +105,138 @@
|
||||
.stock-ok { color: #22c55e; }
|
||||
.stock-low { color: #f59e0b; }
|
||||
.stock-empty { color: #ef4444; }
|
||||
|
||||
/* ── Mobile offcanvas nav — mêmes styles que #sidebar nav ──────────── */
|
||||
#mobileSidebar nav { flex: 1; padding: .5rem 0; }
|
||||
#mobileSidebar nav a {
|
||||
display: flex; align-items: center; gap: .65rem;
|
||||
padding: .6rem 1.25rem; color: #9ba3af;
|
||||
text-decoration: none; font-size: .875rem;
|
||||
transition: background .15s, color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#mobileSidebar nav a:hover,
|
||||
#mobileSidebar nav a.active { background: #2e323d; color: #fff; }
|
||||
#mobileSidebar nav a.active { border-left: 3px solid #ff6b35; }
|
||||
#mobileSidebar nav a i { flex-shrink: 0; font-size: 1rem; width: 1.1rem; text-align: center; }
|
||||
#mobileSidebar nav .nav-label { opacity: 1 !important; pointer-events: auto !important; }
|
||||
#mobileSidebar nav .nav-section {
|
||||
padding: .75rem 1.25rem .25rem; font-size: .7rem;
|
||||
text-transform: uppercase; letter-spacing: .08em; color: #4b5563;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
#mobileSidebar .sidebar-user {
|
||||
padding: .75rem 1.25rem; border-top: 1px solid #2e323d;
|
||||
opacity: 1 !important; pointer-events: auto !important;
|
||||
}
|
||||
#mobileSidebar .sidebar-user div { font-size: .75rem; color: #9ba3af; }
|
||||
#mobileSidebar .sidebar-user a {
|
||||
font-size: .72rem; color: #6b7280; text-decoration: none; display: block; margin-top: .25rem;
|
||||
}
|
||||
|
||||
/* ── Mobile responsive ───────────────────────────────────────────────── */
|
||||
@media (max-width: 767.98px) {
|
||||
#sidebar { display: none !important; }
|
||||
#main { margin-left: 0 !important; padding: 1rem 0.75rem; }
|
||||
/* Empêcher le zoom auto iOS sur les inputs */
|
||||
input, select, textarea { font-size: max(1rem, 16px) !important; }
|
||||
.card { border-radius: 8px; }
|
||||
.stat-card { padding: 1rem; }
|
||||
.stat-card .value { font-size: 1.4rem; }
|
||||
.final-price { font-size: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── Mobile top bar (d-md-none = visible seulement < 768px) ─────────── -->
|
||||
<nav id="mobileTopBar"
|
||||
class="d-flex d-md-none align-items-center sticky-top px-3 gap-2"
|
||||
style="background:#1a1d23;height:52px;z-index:1100">
|
||||
<button class="btn p-1" style="color:#9ba3af;font-size:1.3rem;line-height:1"
|
||||
data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Menu">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
<span class="text-white fw-bold">
|
||||
<i class="bi bi-layers-half me-1"></i>3D<span style="color:#ff6b35">Pricing</span>
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<!-- ── Mobile offcanvas sidebar ──────────────────────────────────────── -->
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar"
|
||||
style="background:#1a1d23;width:240px">
|
||||
<div class="offcanvas-header" style="border-bottom:1px solid #2e323d;padding:.75rem 1.25rem">
|
||||
<span class="text-white fw-bold">
|
||||
<i class="bi bi-layers-half me-1"></i>3D<span style="color:#ff6b35">Pricing</span>
|
||||
</span>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body p-0" style="overflow-y:auto">
|
||||
<div id="mobileNavInner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sidebar">
|
||||
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
|
||||
<div class="brand">
|
||||
<span class="brand-text"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></span>
|
||||
<button id="sidebarToggle" title="Réduire / Agrandir">
|
||||
<i class="bi bi-layout-sidebar-reverse" id="sidebarToggleIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<nav>
|
||||
<div class="nav-section">Navigation</div>
|
||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}" title="Dashboard">
|
||||
<i class="bi bi-speedometer2"></i><span class="nav-label"> Dashboard</span>
|
||||
</a>
|
||||
<a href="{{ url_for('jobs') }}" class="{% if request.endpoint in ['jobs','job_detail'] %}active{% endif %}">
|
||||
<i class="bi bi-printer"></i> Commandes
|
||||
<a href="{{ url_for('jobs') }}" class="{% if request.endpoint in ['jobs','job_detail'] %}active{% endif %}" title="Commandes">
|
||||
<i class="bi bi-printer"></i><span class="nav-label"> Commandes</span>
|
||||
</a>
|
||||
<a href="{{ url_for('new_job') }}" class="{% if request.endpoint=='new_job' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> Nouveau calcul
|
||||
<a href="{{ url_for('new_job') }}" class="{% if request.endpoint=='new_job' %}active{% endif %}" title="Nouveau calcul">
|
||||
<i class="bi bi-plus-circle"></i><span class="nav-label"> Nouveau calcul</span>
|
||||
</a>
|
||||
<a href="{{ url_for('clients') }}" class="{% if 'client' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-building"></i> Clients
|
||||
<a href="{{ url_for('clients') }}" class="{% if 'client' in request.endpoint %}active{% endif %}" title="Clients">
|
||||
<i class="bi bi-building"></i><span class="nav-label"> Clients</span>
|
||||
</a>
|
||||
<a href="{{ url_for('materials') }}" class="{% if 'material' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-boxes"></i> Matières
|
||||
<a href="{{ url_for('materials') }}" class="{% if 'material' in request.endpoint %}active{% endif %}" title="Matières">
|
||||
<i class="bi bi-boxes"></i><span class="nav-label"> Matières</span>
|
||||
</a>
|
||||
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
|
||||
<i class="bi bi-bookmark-star"></i> Templates
|
||||
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}" title="Templates">
|
||||
<i class="bi bi-bookmark-star"></i><span class="nav-label"> Templates</span>
|
||||
</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 href="{{ url_for('projects') }}" class="{% if request.endpoint in ['projects','new_project','edit_project','project_detail'] %}active{% endif %}" title="Projets">
|
||||
<i class="bi bi-folder2-open"></i><span class="nav-label"> Projets</span>
|
||||
</a>
|
||||
<a href="{{ url_for('planning') }}" class="{% if request.endpoint in ['planning','printers_page','working_schedule','calendar_blocks'] %}active{% endif %}" title="Planning">
|
||||
<i class="bi bi-calendar3"></i><span class="nav-label"> Planning</span>
|
||||
</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
|
||||
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}" title="Statistiques">
|
||||
<i class="bi bi-bar-chart-line"></i><span class="nav-label"> Statistiques</span>
|
||||
</a>
|
||||
<a href="{{ url_for('export_csv') }}">
|
||||
<i class="bi bi-download"></i> Export CSV
|
||||
<a href="{{ url_for('export_csv') }}" title="Export CSV">
|
||||
<i class="bi bi-download"></i><span class="nav-label"> Export CSV</span>
|
||||
</a>
|
||||
<div class="nav-section">Config</div>
|
||||
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
|
||||
<i class="bi bi-sliders"></i> Paramètres
|
||||
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}" title="Paramètres">
|
||||
<i class="bi bi-sliders"></i><span class="nav-label"> Paramètres</span>
|
||||
</a>
|
||||
<a href="{{ url_for('mobile_quick') }}" class="{% if request.endpoint=='mobile_quick' %}active{% endif %}" title="Vue mobile">
|
||||
<i class="bi bi-phone"></i><span class="nav-label"> Vue mobile</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{% if session.user %}
|
||||
<div class="sidebar-user" style="padding:.75rem 1.25rem;border-top:1px solid #2e323d;margin-top:auto">
|
||||
<div style="font-size:.75rem;color:#9ba3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
|
||||
title="{{ session.user.email }}">
|
||||
<i class="bi bi-person-circle me-1"></i>{{ session.user.name }}
|
||||
</div>
|
||||
<a href="{{ url_for('logout') }}"
|
||||
style="font-size:.72rem;color:#6b7280;text-decoration:none;display:block;margin-top:.25rem">
|
||||
<i class="bi bi-box-arrow-left me-1"></i>Déconnexion
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="main">
|
||||
@@ -116,6 +251,70 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const SIDEBAR_KEY = 'sidebar_collapsed';
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('main');
|
||||
const btn = document.getElementById('sidebarToggle');
|
||||
const icon = document.getElementById('sidebarToggleIcon');
|
||||
|
||||
function applyState(collapsed, animate) {
|
||||
if (!animate) {
|
||||
sidebar.style.transition = 'none';
|
||||
main.style.transition = 'none';
|
||||
}
|
||||
sidebar.classList.toggle('collapsed', collapsed);
|
||||
main.classList.toggle('sidebar-collapsed', collapsed);
|
||||
icon.className = collapsed
|
||||
? 'bi bi-layout-sidebar'
|
||||
: 'bi bi-layout-sidebar-reverse';
|
||||
if (!animate) {
|
||||
// Force reflow then restore transition
|
||||
sidebar.offsetHeight;
|
||||
sidebar.style.transition = '';
|
||||
main.style.transition = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore state without animation on page load
|
||||
const stored = localStorage.getItem(SIDEBAR_KEY) === 'true';
|
||||
applyState(stored, false);
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const nowCollapsed = !sidebar.classList.contains('collapsed');
|
||||
applyState(nowCollapsed, true);
|
||||
localStorage.setItem(SIDEBAR_KEY, nowCollapsed);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<!-- ── Peuple l'offcanvas mobile depuis le sidebar desktop ───────────── -->
|
||||
<script>
|
||||
(function () {
|
||||
const sidebarNav = document.querySelector('#sidebar nav');
|
||||
const sidebarUser = document.querySelector('#sidebar .sidebar-user');
|
||||
const mobileInner = document.getElementById('mobileNavInner');
|
||||
if (!sidebarNav || !mobileInner) return;
|
||||
|
||||
// Clone le nav et force l'affichage des labels (jamais en mode collapsed)
|
||||
const navClone = sidebarNav.cloneNode(true);
|
||||
navClone.querySelectorAll('.nav-label, .nav-section').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
el.style.pointerEvents = 'auto';
|
||||
});
|
||||
// Ferme l'offcanvas au clic sur un lien
|
||||
navClone.querySelectorAll('a').forEach(a => a.setAttribute('data-bs-dismiss', 'offcanvas'));
|
||||
mobileInner.appendChild(navClone);
|
||||
|
||||
// Clone la zone utilisateur en bas
|
||||
if (sidebarUser) {
|
||||
const userClone = sidebarUser.cloneNode(true);
|
||||
userClone.style.cssText += ';opacity:1;pointer-events:auto;margin-top:auto';
|
||||
userClone.querySelectorAll('a').forEach(a => a.setAttribute('data-bs-dismiss', 'offcanvas'));
|
||||
mobileInner.appendChild(userClone);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,9 +14,15 @@
|
||||
<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 class="row g-2 mb-3">
|
||||
<div class="col">
|
||||
<label class="form-label fw-semibold">Date de début</label>
|
||||
<input type="date" name="date_start" id="dateStart" class="form-control" required>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label fw-semibold">Date de fin <span class="text-muted fw-normal small">(optionnel)</span></label>
|
||||
<input type="date" name="date_end" id="dateEnd" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Type</label>
|
||||
@@ -55,12 +61,17 @@
|
||||
{% 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>
|
||||
<tr><th>Période</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 class="fw-semibold" style="white-space:nowrap">
|
||||
{{ b.date }}
|
||||
{% if b.date_end and b.date_end != b.date %}
|
||||
<span class="text-muted fw-normal">→</span> {{ b.date_end }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if b.type == 'off' %}<span class="badge bg-danger">Off</span>
|
||||
{% elif b.type == 'tt' %}<span class="badge bg-primary">TT</span>
|
||||
@@ -89,3 +100,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const dateStart = document.getElementById('dateStart');
|
||||
const dateEnd = document.getElementById('dateEnd');
|
||||
dateStart.addEventListener('change', () => {
|
||||
dateEnd.min = dateStart.value;
|
||||
if (dateEnd.value && dateEnd.value < dateStart.value) dateEnd.value = dateStart.value;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Suivi commande — {{ job.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f4f6f9; }
|
||||
.topbar { background: #1a1d23; color: #fff; padding: 1rem 1.5rem; }
|
||||
.topbar .brand { font-weight: 800; font-size: 1.2rem; }
|
||||
.topbar .brand span { color: #ff6b35; }
|
||||
.card { border: none; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.stat-val { font-size: 1.5rem; font-weight: 700; }
|
||||
.timeline { position: relative; padding-left: 2rem; }
|
||||
.timeline::before { content: ''; position: absolute; left: .6rem; top: 0; bottom: 0;
|
||||
width: 2px; background: #dee2e6; }
|
||||
.timeline-item { position: relative; margin-bottom: 1.2rem; }
|
||||
.timeline-item::before { content: ''; position: absolute; left: -1.55rem; top: .3rem;
|
||||
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 2px #dee2e6; background: #6b7280; }
|
||||
.timeline-item.done::before { background: #22c55e; box-shadow: 0 0 0 2px #22c55e; }
|
||||
.timeline-item.failed::before { background: #ef4444; box-shadow: 0 0 0 2px #ef4444; }
|
||||
.timeline-item.planned::before{ background: #3b82f6; box-shadow: 0 0 0 2px #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar d-flex align-items-center justify-content-between">
|
||||
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
|
||||
<small class="text-secondary">Suivi de commande</small>
|
||||
</div>
|
||||
|
||||
<div class="container py-4" style="max-width:720px">
|
||||
|
||||
<!-- En-tête commande -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h4 class="fw-bold mb-1">{{ job.name }}</h4>
|
||||
{% if job.client_name %}
|
||||
<div class="text-muted small mb-3"><i class="bi bi-building me-1"></i>{{ job.client_name }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
{% set status_map = {'planned':'En cours','done':'Terminé','failed':'Échec','cancelled':'Annulé'} %}
|
||||
{% set slot_statuses = slots | map(attribute='status') | list %}
|
||||
{% if 'done' in slot_statuses %}
|
||||
<span class="badge bg-success fs-6 px-3 py-2">✅ Terminé</span>
|
||||
{% elif 'failed' in slot_statuses %}
|
||||
<span class="badge bg-danger fs-6 px-3 py-2">❌ Incident</span>
|
||||
{% elif slots %}
|
||||
<span class="badge bg-primary fs-6 px-3 py-2">🖨️ En cours</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary fs-6 px-3 py-2">📋 Planifié</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="text-muted small">Pièces commandées</div>
|
||||
<div class="stat-val">{{ job.order_qty or 1 }}</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="text-muted small">Matière</div>
|
||||
<div class="fw-semibold">
|
||||
{% if job.material_name %}
|
||||
{% if job.material_color %}
|
||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;
|
||||
background:{{ job.material_color | css_color }};border:1px solid #ccc;
|
||||
vertical-align:middle;margin-right:4px"></span>
|
||||
{% endif %}
|
||||
{{ job.material_name }}
|
||||
{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3 text-center">
|
||||
<div class="text-muted small">Créé le</div>
|
||||
<div class="fw-semibold">{{ job.created_at[:10] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.description %}
|
||||
<hr class="my-3">
|
||||
<p class="text-muted small mb-0"><i class="bi bi-info-circle me-1"></i>{{ job.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline créneaux -->
|
||||
{% if slots %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3"><i class="bi bi-printer me-2"></i>Suivi d'impression</h6>
|
||||
<div class="timeline">
|
||||
{% for s in slots %}
|
||||
<div class="timeline-item {{ s.status }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="fw-semibold">{{ s.printer_name }}</div>
|
||||
<div class="small text-muted">
|
||||
{{ s.planned_start[:16].replace('T', ' ') }} →
|
||||
{{ s.planned_end[11:16] }}
|
||||
</div>
|
||||
{% if s.failure_note %}
|
||||
<div class="small text-danger mt-1">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>{{ s.failure_note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge
|
||||
{% if s.status == 'done' %}bg-success
|
||||
{% elif s.status == 'failed' %}bg-danger
|
||||
{% elif s.status == 'planned' %}bg-primary
|
||||
{% else %}bg-secondary{% endif %} ms-2">
|
||||
{{ {'done':'Terminé','failed':'Échec','planned':'Planifié','cancelled':'Annulé'}.get(s.status, s.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center text-muted py-4">
|
||||
<i class="bi bi-calendar-plus fs-2 d-block mb-2"></i>
|
||||
Impression non encore planifiée.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center text-muted small">
|
||||
<i class="bi bi-shield-lock me-1"></i>
|
||||
Cette page est partagée uniquement pour le suivi de votre commande.
|
||||
Les informations de tarification ne sont pas visibles.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+783
-11
@@ -9,11 +9,29 @@
|
||||
</a>
|
||||
<h1 class="mt-1">{{ job.name }}</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-secondary" id="shareBtn"
|
||||
title="{{ 'Lien client actif — cliquer pour copier' if job.client_token else 'Générer un lien client' }}">
|
||||
{% if job.client_token %}
|
||||
<i class="bi bi-share-fill me-1 text-success"></i>Lien client
|
||||
{% else %}
|
||||
<i class="bi bi-share me-1"></i>Partager
|
||||
{% endif %}
|
||||
</button>
|
||||
{% if job.client_token %}
|
||||
<button class="btn btn-sm btn-outline-danger" id="revokeBtn" title="Révoquer le lien">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('new_job', clone_from=job.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-copy me-1"></i>Dupliquer
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('delete_job', id=job.id) }}"
|
||||
onsubmit="return confirm('Supprimer cette commande ?')">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set qty = job.order_qty or 1 %}
|
||||
{% set multi = qty > 1 %}
|
||||
@@ -23,6 +41,7 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-3">Informations</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><th class="text-muted fw-normal" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Matiere</th>
|
||||
@@ -33,7 +52,34 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th class="text-muted fw-normal">Fichier source</th>
|
||||
<td class="small font-monospace">{{ job.source_file or '—' }}</td></tr>
|
||||
<td class="small">
|
||||
{% if job.source_file %}
|
||||
<span class="font-monospace">{{ job.source_file.split('/')[-1] }}</span>
|
||||
{% set ext = job.source_file.rsplit('.',1)[-1].lower() %}
|
||||
{% if ext in ['stl','3mf','step','stp','obj'] %}
|
||||
{% set is_nc_path = job.source_file.startswith('/') %}
|
||||
{% if is_nc_path %}
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-1 ms-1"
|
||||
style="font-size:.7rem"
|
||||
data-bs-toggle="modal" data-bs-target="#viewerModal"
|
||||
data-file="{{ job.source_file }}" data-ext="{{ ext }}" data-src="nextcloud">
|
||||
<i class="bi bi-box me-1"></i>Voir
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="btn btn-xs btn-outline-secondary py-0 px-1 ms-1"
|
||||
style="font-size:.7rem"
|
||||
data-bs-toggle="modal" data-bs-target="#viewerModal"
|
||||
data-file="" data-ext="{{ ext }}" data-src="upload">
|
||||
<i class="bi bi-upload me-1"></i>Charger pour voir
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}—{% endif %}
|
||||
</td></tr>
|
||||
{% if job.plate_name %}
|
||||
<tr><th class="text-muted fw-normal">Référence plateau</th>
|
||||
<td><span class="badge bg-light text-dark border">{{ job.plate_name }}</span></td></tr>
|
||||
{% endif %}
|
||||
<tr><th class="text-muted fw-normal">Date</th><td>{{ job.created_at[:10] }}</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Poids plateau</th><td>{{ job.weight_g }} g</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
|
||||
@@ -52,6 +98,7 @@
|
||||
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
{% if job.notes %}
|
||||
<hr>
|
||||
<small class="text-muted">{{ job.notes }}</small>
|
||||
@@ -73,7 +120,7 @@
|
||||
{# Macro pour une ligne à 1 ou 2 colonnes #}
|
||||
{% macro brow(label, val_piece, cls='', precision=2) %}
|
||||
<div class="breakdown-row {{ cls }}">
|
||||
<span class="flex-fill">{{ label }}</span>
|
||||
<span class="flex-fill">{{ label | safe }}</span>
|
||||
<span class="text-end" style="min-width:85px">
|
||||
{% if precision == 4 %}{{ "%.4f"|format(val_piece) }} €
|
||||
{% else %}{{ "%.2f"|format(val_piece) }} €{% endif %}
|
||||
@@ -101,20 +148,36 @@
|
||||
{% set prix_ht_net = prix_ttc - (job.tva_amount or 0) %}
|
||||
{% set marge_pct_ht = (tot_m / prix_ht_net * 100)|round(1) if prix_ht_net > 0 else 0 %}
|
||||
|
||||
{# Macro pour une ligne simple (label = texte pur) #}
|
||||
{# Pour les labels avec HTML, les lignes sont écrites directement ci-dessous #}
|
||||
|
||||
<!-- Cout fixe -->
|
||||
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
|
||||
{{ brow('Matiere' + (' <small class="text-secondary">' + job.material_name + '</small>' if job.material_name else ''), job.material_cost, '', 4) }}
|
||||
{{ brow('Marge matiere', job.material_margin, '', 4) }}
|
||||
{{ brow('Usure machine', job.wear_cost, '', 4) }}
|
||||
{{ brow('Electricite', job.electricity_cost, '', 4) }}
|
||||
{{ brow('<span class="fw-semibold">Total cout fixe</span>', cout_f, 'subtotal') }}
|
||||
|
||||
<div class="breakdown-row">
|
||||
<span class="flex-fill breakdown-muted ps-2">Matiere{% if job.material_name %} <small class="text-secondary">{{ job.material_name }}</small>{% endif %}</span>
|
||||
<span class="text-end" style="min-width:85px">{{ "%.4f"|format(job.material_cost) }} €</span>
|
||||
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(job.material_cost * qty) }} €</span>{% endif %}
|
||||
</div>
|
||||
{{ brow('Marge matiere', job.material_margin, 'breakdown-muted ps-2', 4) }}
|
||||
{{ brow('Usure machine', job.wear_cost, 'breakdown-muted ps-2', 4) }}
|
||||
{{ brow('Electricite', job.electricity_cost, 'breakdown-muted ps-2', 4) }}
|
||||
<div class="breakdown-row subtotal">
|
||||
<span class="flex-fill fw-semibold">Total cout fixe</span>
|
||||
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(cout_f) }} €</span>
|
||||
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(cout_f * qty) }} €</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Partie variable -->
|
||||
<div class="breakdown-row section-header"><span>Partie variable</span></div>
|
||||
{{ brow('Manutention', job.handling_cost, '', 4) }}
|
||||
{{ brow('Design (×' + job.design_multiplier|string + ')', job.design_cost, '', 4) }}
|
||||
{{ brow('Marge brute (' + job.gross_margin_pct|string + '%)', job.margin_amount) }}
|
||||
{{ brow('<span class="fw-semibold">Total marge <small class="text-muted fw-normal">(' + marge_pct_ht|string + '% du HT)</small></span>', tot_m, 'subtotal') }}
|
||||
{{ brow('Manutention', job.handling_cost, 'breakdown-muted ps-2', 4) }}
|
||||
{{ brow('Design (×' ~ job.design_multiplier ~ ')', job.design_cost, 'breakdown-muted ps-2', 4) }}
|
||||
{{ brow('Marge brute (' ~ job.gross_margin_pct ~ '%)', job.margin_amount, 'breakdown-muted ps-2') }}
|
||||
<div class="breakdown-row subtotal">
|
||||
<span class="flex-fill fw-semibold">Total marge <small class="text-muted fw-normal">({{ marge_pct_ht }}% du HT)</small></span>
|
||||
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(tot_m) }} €</span>
|
||||
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(tot_m * qty) }} €</span>{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Fiscal -->
|
||||
<div class="breakdown-row section-header"><span>Fiscal</span></div>
|
||||
@@ -186,4 +249,713 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Plateaux d'impression ────────────────────────────────────────────── -->
|
||||
{% set plates_done = slots | selectattr('status','in',['done']) | list | length %}
|
||||
{% set plates_running = slots | selectattr('status','equalto','running') | list | length %}
|
||||
{% set plates_planned = slots | selectattr('status','in',['planned','running']) | list | length %}
|
||||
{% set plates_total = slots | rejectattr('status','in',['cancelled']) | list | length %}
|
||||
{% set missing_plates = [total_plates_needed - plates_total, 0] | max %}
|
||||
|
||||
<div class="row g-4 mt-0">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span><i class="bi bi-layers-half me-2"></i>Plateaux d'impression</span>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<!-- Progress pill: X/Y plateaux -->
|
||||
{% if total_plates_needed > 1 %}
|
||||
<span class="badge rounded-pill
|
||||
{% if plates_done >= total_plates_needed %}bg-success
|
||||
{% elif missing_plates > 0 %}bg-warning text-dark
|
||||
{% else %}bg-primary{% endif %}"
|
||||
style="font-size:.8rem">
|
||||
{{ plates_done }}/{{ total_plates_needed }} terminé{{ 's' if plates_done > 1 else '' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if missing_plates > 0 %}
|
||||
<span class="badge bg-warning text-dark" style="font-size:.75rem">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} plateau{{ 'x' if missing_plates > 1 else '' }} à planifier
|
||||
</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary py-0">
|
||||
<i class="bi bi-calendar-plus me-1"></i>Planning
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if slots %}
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small ps-3">#</th>
|
||||
<th class="small">Début</th>
|
||||
<th class="small">Machine</th>
|
||||
<th class="small">Pièces</th>
|
||||
<th class="small">Statut / Progression</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in slots %}
|
||||
{% if s.status != 'cancelled' %}
|
||||
<tr id="slotRow{{ s.id }}">
|
||||
<td class="ps-3 text-muted small">{{ loop.index }}</td>
|
||||
<td class="small" style="white-space:nowrap">
|
||||
{{ s.planned_start[:16].replace('T',' ') }}
|
||||
</td>
|
||||
<td class="small fw-semibold">{{ s.printer_name }}</td>
|
||||
<td class="small">
|
||||
{% set ep = s.effective_pieces %}
|
||||
{% set ig = s.pieces_ignored %}
|
||||
{% if ig > 0 %}
|
||||
<span class="text-warning fw-semibold">{{ ep - ig }}/{{ ep }}</span>
|
||||
<span class="text-muted" style="font-size:.7rem"> ({{ ig }} ignorée{{ 's' if ig > 1 }})</span>
|
||||
{% else %}
|
||||
{{ ep }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set sc = {'planned':'secondary','running':'primary','done':'success','failed':'danger'} %}
|
||||
{% set sl = {'planned':'Planifié','running':'En cours','done':'Terminé','failed':'Échec'} %}
|
||||
<span class="badge bg-{{ sc.get(s.status,'secondary') }}">{{ sl.get(s.status, s.status) }}</span>
|
||||
{% if s.status == 'running' and s.ha_entity_prefix %}
|
||||
<!-- Live HA progress injected by JS -->
|
||||
<span id="haLive{{ s.id }}" class="ms-2 small text-muted">
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if s.failure_note %}
|
||||
<br><span class="text-muted" style="font-size:.72rem">{{ s.failure_note }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- HA live progress bar row (hidden until JS fills it) -->
|
||||
{% if s.status == 'running' and s.ha_entity_prefix %}
|
||||
<tr id="haBar{{ s.id }}" class="table-primary">
|
||||
<td colspan="5" class="px-3 py-1">
|
||||
<div class="progress" style="height:6px;border-radius:3px">
|
||||
<div id="haBarInner{{ s.id }}" class="progress-bar bg-primary" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="haBarInfo{{ s.id }}" class="text-muted mt-1" style="font-size:.72rem"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-center text-muted py-4 small">
|
||||
<i class="bi bi-calendar-x fs-3 d-block mb-2"></i>
|
||||
Aucun créneau planifié pour cette commande.
|
||||
<br><a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary mt-2">
|
||||
<i class="bi bi-calendar-plus me-1"></i>Aller au planning
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Global progress bar for multi-plate jobs -->
|
||||
{% if total_plates_needed > 1 %}
|
||||
<div class="card-footer py-2 px-3" style="background:#fafafa;border-top:1px solid #f0f0f0">
|
||||
{% set pct = ((plates_done / total_plates_needed) * 100) | round(0) | int %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Progression commande</small>
|
||||
<small class="fw-semibold">{{ pct }}%</small>
|
||||
</div>
|
||||
<div class="progress" style="height:8px;border-radius:4px">
|
||||
<div class="progress-bar bg-success" style="width:{{ pct }}%"></div>
|
||||
{% if plates_running > 0 %}
|
||||
<div class="progress-bar bg-primary progress-bar-striped progress-bar-animated"
|
||||
style="width:{{ ((plates_running / total_plates_needed) * 100) | round(0) | int }}%"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 d-flex gap-3" style="font-size:.72rem;color:#6b7280">
|
||||
<span><i class="bi bi-check-circle-fill text-success me-1"></i>{{ plates_done }} terminé{{ 's' if plates_done > 1 }}</span>
|
||||
{% if plates_running %}<span><i class="bi bi-arrow-repeat text-primary me-1"></i>{{ plates_running }} en cours</span>{% endif %}
|
||||
{% set plates_sched = plates_planned - plates_running %}
|
||||
{% if plates_sched %}<span><i class="bi bi-clock text-secondary me-1"></i>{{ plates_sched }} planifié{{ 's' if plates_sched > 1 }}</span>{% endif %}
|
||||
{% if missing_plates %}<span class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} manquant{{ 's' if missing_plates > 1 }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Plateaux d'impression ────────────────────────────────────────────── -->
|
||||
{% set plates_done = slots | selectattr('status','in',['done']) | list | length %}
|
||||
{% set plates_running = slots | selectattr('status','equalto','running') | list | length %}
|
||||
{% set plates_planned = slots | selectattr('status','in',['planned','running']) | list | length %}
|
||||
{% set plates_total = slots | rejectattr('status','in',['cancelled']) | list | length %}
|
||||
{% set missing_plates = [total_plates_needed - plates_total, 0] | max %}
|
||||
|
||||
<div class="row g-4 mt-0">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<span><i class="bi bi-layers-half me-2"></i>Plateaux d'impression</span>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
{% if total_plates_needed > 1 %}
|
||||
<span class="badge rounded-pill
|
||||
{% if plates_done >= total_plates_needed %}bg-success
|
||||
{% elif missing_plates > 0 %}bg-warning text-dark
|
||||
{% else %}bg-primary{% endif %}"
|
||||
style="font-size:.8rem">
|
||||
{{ plates_done }}/{{ total_plates_needed }} terminé{{ 's' if plates_done > 1 else '' }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if missing_plates > 0 %}
|
||||
<span class="badge bg-warning text-dark" style="font-size:.75rem">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} plateau{{ 'x' if missing_plates > 1 else '' }} à planifier
|
||||
</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary py-0">
|
||||
<i class="bi bi-calendar-plus me-1"></i>Planning
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if slots %}
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small ps-3">#</th>
|
||||
<th class="small">Début</th>
|
||||
<th class="small">Machine</th>
|
||||
<th class="small">Pièces</th>
|
||||
<th class="small">Statut / Progression</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in slots %}
|
||||
{% if s.status != 'cancelled' %}
|
||||
<tr id="slotRow{{ s.id }}">
|
||||
<td class="ps-3 text-muted small">{{ loop.index }}</td>
|
||||
<td class="small" style="white-space:nowrap">{{ s.planned_start[:16].replace('T',' ') }}</td>
|
||||
<td class="small fw-semibold">{{ s.printer_name }}</td>
|
||||
<td class="small">
|
||||
{% set ep = s.effective_pieces %}
|
||||
{% set ig = s.pieces_ignored %}
|
||||
{% if ig > 0 %}
|
||||
<span class="text-warning fw-semibold">{{ ep - ig }}/{{ ep }}</span>
|
||||
<span class="text-muted" style="font-size:.7rem"> ({{ ig }} ignorée{{ 's' if ig > 1 }})</span>
|
||||
{% else %}{{ ep }}{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% set sc = {'planned':'secondary','running':'primary','done':'success','failed':'danger'} %}
|
||||
{% set sl = {'planned':'Planifié','running':'En cours','done':'Terminé','failed':'Échec'} %}
|
||||
<span class="badge bg-{{ sc.get(s.status,'secondary') }}">{{ sl.get(s.status, s.status) }}</span>
|
||||
{% if s.status == 'running' and s.ha_entity_prefix %}
|
||||
<span id="haLive{{ s.id }}" class="ms-2 small text-muted">
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if s.failure_note %}<br><span class="text-muted" style="font-size:.72rem">{{ s.failure_note }}</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if s.status == 'running' and s.ha_entity_prefix %}
|
||||
<tr id="haBar{{ s.id }}" class="table-primary">
|
||||
<td colspan="5" class="px-3 py-1">
|
||||
<div class="progress" style="height:6px;border-radius:3px">
|
||||
<div id="haBarInner{{ s.id }}" class="progress-bar bg-primary" style="width:0%"></div>
|
||||
</div>
|
||||
<div id="haBarInfo{{ s.id }}" class="text-muted mt-1" style="font-size:.72rem"></div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-center text-muted py-4 small">
|
||||
<i class="bi bi-calendar-x fs-3 d-block mb-2"></i>Aucun créneau planifié.
|
||||
<br><a href="{{ url_for('planning') }}" class="btn btn-sm btn-outline-primary mt-2">
|
||||
<i class="bi bi-calendar-plus me-1"></i>Aller au planning
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if total_plates_needed > 1 %}
|
||||
<div class="card-footer py-2 px-3" style="background:#fafafa;border-top:1px solid #f0f0f0">
|
||||
{% set pct = ((plates_done / total_plates_needed) * 100) | round(0) | int %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Progression commande</small>
|
||||
<small class="fw-semibold">{{ pct }}%</small>
|
||||
</div>
|
||||
<div class="progress" style="height:8px;border-radius:4px">
|
||||
<div class="progress-bar bg-success" style="width:{{ pct }}%"></div>
|
||||
{% if plates_running > 0 %}
|
||||
<div class="progress-bar bg-primary progress-bar-striped progress-bar-animated"
|
||||
style="width:{{ ((plates_running / total_plates_needed) * 100) | round(0) | int }}%"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 d-flex gap-3" style="font-size:.72rem;color:#6b7280">
|
||||
<span><i class="bi bi-check-circle-fill text-success me-1"></i>{{ plates_done }} terminé{{ 's' if plates_done > 1 }}</span>
|
||||
{% if plates_running %}<span><i class="bi bi-arrow-repeat text-primary me-1"></i>{{ plates_running }} en cours</span>{% endif %}
|
||||
{% set plates_sched = plates_planned - plates_running %}
|
||||
{% if plates_sched %}<span><i class="bi bi-clock text-secondary me-1"></i>{{ plates_sched }} planifié{{ 's' if plates_sched > 1 }}</span>{% endif %}
|
||||
{% if missing_plates %}<span class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>{{ missing_plates }} manquant{{ 's' if missing_plates > 1 }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Modale viewer 3D ──────────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="viewerModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content" style="background:#1a1d23">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<span class="text-white fw-semibold" id="viewerTitle">Viewer 3D</span>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-light" id="viewerWire">Wireframe</button>
|
||||
<button class="btn btn-sm btn-outline-light" id="viewerReset">Reset vue</button>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="viewerUploadZone" class="p-4 text-center" style="display:none">
|
||||
<i class="bi bi-cloud-upload fs-1 text-white-50 d-block mb-3"></i>
|
||||
<p class="text-white-50 mb-3">Le fichier source n'est pas sur Nextcloud.<br>Chargez-le manuellement pour le visualiser.</p>
|
||||
<input type="file" id="viewerLocalFile" class="form-control form-control-sm mx-auto" style="max-width:300px" accept=".stl,.3mf,.obj">
|
||||
</div>
|
||||
<div class="modal-body p-0 position-relative" style="height:75vh" id="viewerCanvasWrap">
|
||||
<canvas id="viewerCanvas" style="width:100%;height:100%;display:block"></canvas>
|
||||
<div id="viewerLoader" class="position-absolute top-50 start-50 translate-middle text-white text-center">
|
||||
<div class="spinner-border mb-2"></div><br>Chargement du modèle…
|
||||
</div>
|
||||
<div id="viewerError" class="position-absolute top-50 start-50 translate-middle text-danger text-center" style="display:none">
|
||||
<i class="bi bi-exclamation-triangle fs-1 d-block mb-2"></i>
|
||||
<span id="viewerErrorMsg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<!-- ── Live HA status for running slots ──────────────────────────────────── -->
|
||||
{% set running_slots = slots | selectattr('status','equalto','running') | selectattr('ha_entity_prefix') | list %}
|
||||
{% if running_slots %}
|
||||
<script>
|
||||
(async function pollHA() {
|
||||
const runningPrinters = {
|
||||
{% for s in running_slots %}
|
||||
"{{ s.printer_id }}": { slotId: {{ s.id }}, prefix: "{{ s.ha_entity_prefix }}" },
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await fetch('/api/ha/status').then(r => r.json());
|
||||
for (const [printerId, info] of Object.entries(runningPrinters)) {
|
||||
const p = data[printerId];
|
||||
if (!p) continue;
|
||||
const liveEl = document.getElementById('haLive' + info.slotId);
|
||||
const barEl = document.getElementById('haBarInner' + info.slotId);
|
||||
const barInfo = document.getElementById('haBarInfo' + info.slotId);
|
||||
if (!liveEl) continue;
|
||||
|
||||
const pct = p.progress || 0;
|
||||
const layer = p.layer && p.layers_total ? `Couche ${p.layer}/${p.layers_total}` : '';
|
||||
const fin = p.heure_fin ? `Fin ≈ ${p.heure_fin.slice(11,16)}` : '';
|
||||
|
||||
liveEl.innerHTML = `<strong>${pct}%</strong>${layer ? ' · ' + layer : ''}${fin ? ' · ' + fin : ''}`;
|
||||
if (barEl) barEl.style.width = pct + '%';
|
||||
if (barInfo) barInfo.textContent = [layer, fin].filter(Boolean).join(' · ');
|
||||
}
|
||||
} catch(e) {}
|
||||
setTimeout(refresh, 30000);
|
||||
}
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Live HA status for running slots ──────────────────────────────────── -->
|
||||
{% set running_slots = slots | selectattr('status','equalto','running') | selectattr('ha_entity_prefix') | list %}
|
||||
{% if running_slots %}
|
||||
<script>
|
||||
(async function pollHA() {
|
||||
const HA_POLL_MS = {{ ha_poll_interval * 1000 }};
|
||||
const runningPrinters = {
|
||||
{% for s in running_slots %}
|
||||
"{{ s.printer_id }}": { slotId: {{ s.id }}, prefix: "{{ s.ha_entity_prefix }}" },
|
||||
{% endfor %}
|
||||
};
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await fetch('/api/ha/status').then(r => r.json());
|
||||
for (const [printerId, info] of Object.entries(runningPrinters)) {
|
||||
const p = data[printerId];
|
||||
if (!p) continue;
|
||||
const liveEl = document.getElementById('haLive' + info.slotId);
|
||||
const barEl = document.getElementById('haBarInner' + info.slotId);
|
||||
const barInfo = document.getElementById('haBarInfo' + info.slotId);
|
||||
if (!liveEl) continue;
|
||||
const pct = p.progress || 0;
|
||||
const layer = p.layer && p.layers_total ? `Couche ${p.layer}/${p.layers_total}` : '';
|
||||
const fin = p.heure_fin ? `Fin ≈ ${p.heure_fin.slice(11,16)}` : '';
|
||||
liveEl.innerHTML = `<strong>${pct}%</strong>${layer ? ' · ' + layer : ''}${fin ? ' · ' + fin : ''}`;
|
||||
if (barEl) barEl.style.width = pct + '%';
|
||||
if (barInfo) barInfo.textContent = [layer, fin].filter(Boolean).join(' · ');
|
||||
}
|
||||
} catch(e) {}
|
||||
setTimeout(refresh, HA_POLL_MS);
|
||||
}
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- JSZip pour parser les 3MF Bambu Studio -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||
import { ThreeMFLoader } from 'three/addons/loaders/3MFLoader.js';
|
||||
|
||||
const modal = document.getElementById('viewerModal');
|
||||
const canvas = document.getElementById('viewerCanvas');
|
||||
const loader = document.getElementById('viewerLoader');
|
||||
const errDiv = document.getElementById('viewerError');
|
||||
const errMsg = document.getElementById('viewerErrorMsg');
|
||||
const title = document.getElementById('viewerTitle');
|
||||
|
||||
let renderer, scene, camera, controls, modelMesh;
|
||||
let wireMode = false;
|
||||
|
||||
function initRenderer() {
|
||||
if (renderer) return;
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setClearColor(0x1a1d23, 1);
|
||||
renderer.shadowMap.enabled = true;
|
||||
|
||||
scene = new THREE.Scene();
|
||||
camera = new THREE.PerspectiveCamera(45, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
|
||||
|
||||
controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
|
||||
// Lumières
|
||||
const amb = new THREE.AmbientLight(0xffffff, 0.5);
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
dir.position.set(5, 10, 7);
|
||||
scene.add(amb, dir);
|
||||
|
||||
// Grille
|
||||
const grid = new THREE.GridHelper(300, 30, 0x444444, 0x333333);
|
||||
scene.add(grid);
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||
camera.aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
}
|
||||
|
||||
function fitCamera() {
|
||||
if (!modelMesh) return;
|
||||
const box = new THREE.Box3().setFromObject(modelMesh);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
camera.position.set(center.x + maxDim * 1.5, center.y + maxDim, center.z + maxDim * 1.5);
|
||||
controls.target.copy(center);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
const uploadZone = document.getElementById('viewerUploadZone');
|
||||
const canvasWrap = document.getElementById('viewerCanvasWrap');
|
||||
const localFileIn = document.getElementById('viewerLocalFile');
|
||||
|
||||
function makeMat() {
|
||||
return new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
|
||||
}
|
||||
|
||||
function loadFromUrl(url, ext) {
|
||||
loader.style.display = '';
|
||||
errDiv.style.display = 'none';
|
||||
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
|
||||
|
||||
if (ext === 'stl') {
|
||||
const mat = makeMat();
|
||||
new STLLoader().load(url, geo => {
|
||||
geo.computeVertexNormals();
|
||||
modelMesh = new THREE.Mesh(geo, mat);
|
||||
modelMesh.rotation.x = -Math.PI / 2;
|
||||
scene.add(modelMesh);
|
||||
loader.style.display = 'none';
|
||||
fitCamera();
|
||||
}, undefined, (e) => {
|
||||
loader.style.display = 'none';
|
||||
errDiv.style.display = '';
|
||||
errMsg.textContent = 'Erreur STL : ' + (e.message || e);
|
||||
});
|
||||
|
||||
} else if (ext === '3mf') {
|
||||
// Parser Bambu custom : lit directement les 3D/Objects/*.model
|
||||
loadBambu3MF(url);
|
||||
|
||||
} else {
|
||||
loader.style.display = 'none';
|
||||
errDiv.style.display = '';
|
||||
errMsg.textContent = `Format .${ext} non supporté (STL et 3MF uniquement).`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBambu3MF(url) {
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
|
||||
const JSZip = window.JSZip;
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const allPositions = [];
|
||||
const tmpVec = new THREE.Vector3();
|
||||
|
||||
// ── Stratégie A : fichiers externes 3D/Objects/*.model (projet Bambu) ──
|
||||
const objectFiles = Object.keys(zip.files)
|
||||
.filter(n => n.startsWith('3D/Objects/') && n.endsWith('.model'));
|
||||
|
||||
if (objectFiles.length) {
|
||||
// Lire les transforms depuis 3dmodel.model
|
||||
const transforms = {};
|
||||
const mainFile = zip.files['3D/3dmodel.model'];
|
||||
if (mainFile) {
|
||||
const mainXml = await mainFile.async('string');
|
||||
const doc = new DOMParser().parseFromString(mainXml, 'text/xml');
|
||||
doc.querySelectorAll('component').forEach(c => {
|
||||
const path = (c.getAttribute('p:path') || '').replace(/^\//, '');
|
||||
const objId = c.getAttribute('objectid');
|
||||
const tStr = c.getAttribute('transform');
|
||||
if (path && objId) transforms[path + '#' + objId] = tStr || null;
|
||||
});
|
||||
}
|
||||
|
||||
for (const objFile of objectFiles) {
|
||||
const xmlStr = await zip.files[objFile].async('string');
|
||||
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml');
|
||||
for (const objEl of doc.querySelectorAll('object')) {
|
||||
const meshEl = objEl.querySelector('mesh');
|
||||
if (!meshEl) continue;
|
||||
const objId = objEl.getAttribute('id');
|
||||
const tStr = transforms[objFile + '#' + objId];
|
||||
let matrix = null;
|
||||
if (tStr) {
|
||||
const v = tStr.trim().split(/\s+/).map(Number);
|
||||
if (v.length === 12) {
|
||||
matrix = new THREE.Matrix4();
|
||||
matrix.set(v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1);
|
||||
}
|
||||
}
|
||||
parseMeshEl(meshEl, matrix, tmpVec, allPositions);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stratégie B : géométrie inline dans 3dmodel.model (generic / standard 3MF) ──
|
||||
} else {
|
||||
const mainFile = zip.files['3D/3dmodel.model'];
|
||||
if (!mainFile) throw new Error('Aucun fichier 3D trouvé dans le 3MF');
|
||||
const mainXml = await mainFile.async('string');
|
||||
const doc = new DOMParser().parseFromString(mainXml, 'text/xml');
|
||||
|
||||
// Construire la map id→transform depuis <build><item>
|
||||
const buildTransforms = {};
|
||||
doc.querySelectorAll('build item').forEach(item => {
|
||||
const objId = item.getAttribute('objectid');
|
||||
const tStr = item.getAttribute('transform');
|
||||
buildTransforms[objId] = tStr || null;
|
||||
});
|
||||
|
||||
for (const objEl of doc.querySelectorAll('resources object')) {
|
||||
const meshEl = objEl.querySelector('mesh');
|
||||
if (!meshEl) continue;
|
||||
const objId = objEl.getAttribute('id');
|
||||
const tStr = buildTransforms[objId];
|
||||
let matrix = null;
|
||||
if (tStr) {
|
||||
const v = tStr.trim().split(/\s+/).map(Number);
|
||||
if (v.length === 12) {
|
||||
matrix = new THREE.Matrix4();
|
||||
matrix.set(v[0],v[3],v[6],v[9], v[1],v[4],v[7],v[10], v[2],v[5],v[8],v[11], 0,0,0,1);
|
||||
}
|
||||
}
|
||||
parseMeshEl(meshEl, matrix, tmpVec, allPositions);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allPositions.length) throw new Error('Aucune géométrie dans le 3MF');
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(allPositions, 3));
|
||||
geo.computeVertexNormals();
|
||||
modelMesh = new THREE.Mesh(geo, makeMat());
|
||||
modelMesh.rotation.x = -Math.PI / 2;
|
||||
scene.add(modelMesh);
|
||||
loader.style.display = 'none';
|
||||
fitCamera();
|
||||
|
||||
} catch (e) {
|
||||
loader.style.display = 'none';
|
||||
errDiv.style.display = '';
|
||||
errMsg.textContent = 'Erreur 3MF : ' + (e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMeshEl(meshEl, matrix, tmpVec, allPositions) {
|
||||
const verts = Array.from(meshEl.querySelectorAll('vertices vertex'))
|
||||
.map(v => [+v.getAttribute('x'), +v.getAttribute('y'), +v.getAttribute('z')]);
|
||||
for (const tri of meshEl.querySelectorAll('triangles triangle')) {
|
||||
for (const attr of ['v1', 'v2', 'v3']) {
|
||||
const [x, y, z] = verts[+tri.getAttribute(attr)];
|
||||
if (matrix) {
|
||||
tmpVec.set(x, y, z).applyMatrix4(matrix);
|
||||
allPositions.push(tmpVec.x, tmpVec.y, tmpVec.z);
|
||||
} else {
|
||||
allPositions.push(x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function showUploadZone(ext) {
|
||||
uploadZone.style.display = '';
|
||||
canvasWrap.style.display = 'none';
|
||||
loader.style.display = 'none';
|
||||
// Reset file input
|
||||
localFileIn.value = '';
|
||||
localFileIn.onchange = (ev) => {
|
||||
const file = ev.target.files[0];
|
||||
if (!file) return;
|
||||
const detectedExt = file.name.split('.').pop().toLowerCase();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
title.textContent = file.name;
|
||||
uploadZone.style.display = 'none';
|
||||
canvasWrap.style.display = '';
|
||||
initRenderer();
|
||||
loadFromUrl(objectUrl, detectedExt);
|
||||
// Revoke the blob URL after loading
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||
};
|
||||
}
|
||||
|
||||
// Ouvrir la modale
|
||||
modal.addEventListener('show.bs.modal', e => {
|
||||
const btn = e.relatedTarget;
|
||||
const path = btn.dataset.file;
|
||||
const ext = (btn.dataset.ext || '').toLowerCase();
|
||||
const src = btn.dataset.src;
|
||||
|
||||
if (src === 'upload') {
|
||||
title.textContent = 'Charger un fichier local';
|
||||
uploadZone.style.display = '';
|
||||
canvasWrap.style.display = 'none';
|
||||
showUploadZone(ext);
|
||||
} else {
|
||||
title.textContent = path.split('/').pop();
|
||||
uploadZone.style.display = 'none';
|
||||
canvasWrap.style.display = '';
|
||||
initRenderer();
|
||||
loadFromUrl('/api/nextcloud/file?path=' + encodeURIComponent(path), ext);
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer → libérer
|
||||
modal.addEventListener('hidden.bs.modal', () => {
|
||||
if (modelMesh) { scene.remove(modelMesh); modelMesh = null; }
|
||||
uploadZone.style.display = 'none';
|
||||
canvasWrap.style.display = '';
|
||||
});
|
||||
|
||||
// Wireframe toggle
|
||||
document.getElementById('viewerWire').addEventListener('click', function () {
|
||||
wireMode = !wireMode;
|
||||
this.classList.toggle('active', wireMode);
|
||||
if (modelMesh) modelMesh.traverse(c => {
|
||||
if (c.isMesh) c.material.wireframe = wireMode;
|
||||
});
|
||||
});
|
||||
|
||||
// Reset vue
|
||||
document.getElementById('viewerReset').addEventListener('click', fitCamera);
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Partage client ───────────────────────────────────────────────────────────
|
||||
const jobId = {{ job.id }};
|
||||
const shareBtn = document.getElementById('shareBtn');
|
||||
const revokeBtn= document.getElementById('revokeBtn');
|
||||
let currentToken = {{ '"' + job.client_token + '"' if job.client_token else 'null' }};
|
||||
|
||||
if (shareBtn) {
|
||||
shareBtn.addEventListener('click', async () => {
|
||||
if (currentToken) {
|
||||
// Copier l'URL existante
|
||||
const url = location.origin + '/portal/' + currentToken;
|
||||
await navigator.clipboard.writeText(url);
|
||||
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Copié !';
|
||||
setTimeout(() => {
|
||||
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
|
||||
}, 2000);
|
||||
} else {
|
||||
// Générer un nouveau token
|
||||
const r = await fetch(`/jobs/${jobId}/generate-token`, {method:'POST'});
|
||||
const d = await r.json();
|
||||
currentToken = d.token;
|
||||
await navigator.clipboard.writeText(d.url);
|
||||
shareBtn.innerHTML = '<i class="bi bi-check-circle-fill me-1 text-success"></i>Lien copié !';
|
||||
setTimeout(() => {
|
||||
shareBtn.innerHTML = '<i class="bi bi-share-fill me-1 text-success"></i>Lien client';
|
||||
}, 2500);
|
||||
// Ajouter bouton révoquer
|
||||
if (!document.getElementById('revokeBtn')) {
|
||||
const rb = document.createElement('button');
|
||||
rb.id = 'revokeBtn';
|
||||
rb.className = 'btn btn-sm btn-outline-danger';
|
||||
rb.title = 'Révoquer le lien';
|
||||
rb.innerHTML = '<i class="bi bi-x-circle"></i>';
|
||||
shareBtn.insertAdjacentElement('afterend', rb);
|
||||
rb.addEventListener('click', revokeLink);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function revokeLink() {
|
||||
if (!confirm('Révoquer le lien client ? Il ne sera plus accessible.')) return;
|
||||
await fetch(`/jobs/${jobId}/revoke-token`, {method:'POST'});
|
||||
currentToken = null;
|
||||
shareBtn.innerHTML = '<i class="bi bi-share me-1"></i>Partager';
|
||||
shareBtn.title = 'Générer un lien client';
|
||||
document.getElementById('revokeBtn')?.remove();
|
||||
}
|
||||
|
||||
if (revokeBtn) revokeBtn.addEventListener('click', revokeLink);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+14
-3
@@ -17,11 +17,12 @@
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if jobs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th><th>Nom</th><th>Client</th><th>Poids</th>
|
||||
<th>Durée</th><th>Design</th><th>Marge</th><th>Remise</th>
|
||||
<th>Date</th><th>Nom</th><th>Client</th><th>Réf. plateau</th><th>Poids</th>
|
||||
<th>Durée</th><th>Design</th><th>Marge brute</th><th>Total marge</th><th>Remise</th>
|
||||
<th class="text-end">Prix final</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -31,10 +32,12 @@
|
||||
<td class="text-muted small">{{ j.created_at[:10] }}</td>
|
||||
<td><a href="{{ url_for('job_detail', id=j.id) }}" class="text-decoration-none fw-semibold">{{ j.name }}</a></td>
|
||||
<td>{{ j.client_name or '—' }}</td>
|
||||
<td class="text-muted small">{{ j.plate_name or '—' }}</td>
|
||||
<td>{{ j.weight_g }} g</td>
|
||||
<td>{{ j.print_time_s | fmt_time }}</td>
|
||||
<td><span class="badge bg-light text-dark">×{{ j.design_multiplier }}</span></td>
|
||||
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</span></td>
|
||||
<td class="text-nowrap small">{% if j.total_marge %}{{ '%.2f'|format(j.total_marge) }} €<br><span class="text-muted">{% if j.marge_pct_on_ht %}{{ j.marge_pct_on_ht }}% du HT{% endif %}</span>{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
{% if j.discount_pct > 0 %}
|
||||
<span class="badge bg-danger">-{{ j.discount_pct }}%</span>
|
||||
@@ -42,7 +45,14 @@
|
||||
</td>
|
||||
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }} €</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
{% if j.client_token %}
|
||||
<a href="/portal/{{ j.client_token }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-success py-0"
|
||||
title="Portail client actif — ouvrir">
|
||||
<i class="bi bi-share-fill"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary py-0">→</a>
|
||||
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
|
||||
onsubmit="return confirm('Supprimer ?')">
|
||||
@@ -54,6 +64,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-1"></i>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Connexion — 3D Pricing</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f4f6f9; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.login-card { background: #fff; border-radius: 14px; box-shadow: 0 4px 24px rgba(0,0,0,.08); padding: 2.5rem 2rem; width: 100%; max-width: 380px; text-align: center; }
|
||||
.brand { font-size: 1.6rem; font-weight: 800; color: #1a1d23; margin-bottom: .25rem; }
|
||||
.brand span { color: #ff6b35; }
|
||||
.subtitle { color: #6b7280; font-size: .875rem; margin-bottom: 2rem; }
|
||||
.btn-authelia { background: #ff6b35; color: #fff; border: none; border-radius: 8px; padding: .75rem 1.5rem; font-size: 1rem; font-weight: 600; width: 100%; transition: background .15s; }
|
||||
.btn-authelia:hover { background: #e85d2d; color: #fff; }
|
||||
.powered { font-size: .75rem; color: #9ca3af; margin-top: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="brand"><i class="bi bi-layers-half"></i> 3D<span>Pricing</span></div>
|
||||
<div class="subtitle">Gestion & tarification d'impressions 3D</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} mb-3 text-start">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<a href="{{ url_for('login') }}" class="btn btn-authelia">
|
||||
<i class="bi bi-shield-lock me-2"></i>Se connecter avec Authelia
|
||||
</a>
|
||||
|
||||
<div class="powered">
|
||||
Authentification sécurisée via SSO
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,11 +35,34 @@
|
||||
value="{{ material.brand if material else '' }}"
|
||||
placeholder="Bambu, eSUN, Polymaker…">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Couleur</label>
|
||||
<input type="text" name="color" class="form-control"
|
||||
value="{{ material.color if material else '' }}"
|
||||
placeholder="Rouge, #FF0000…">
|
||||
<!-- Valeur soumise (hex) -->
|
||||
<input type="hidden" name="color" id="colorValue"
|
||||
value="{{ (material.color | css_color) if material and material.color else '' }}">
|
||||
|
||||
<!-- Swatches preset -->
|
||||
<div id="swatchGrid" class="mb-2 d-flex flex-wrap gap-1">
|
||||
<!-- Générés par JS -->
|
||||
</div>
|
||||
|
||||
<!-- Ligne : preview + picker natif + hex -->
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="colorPreview"
|
||||
style="width:32px;height:32px;border-radius:6px;border:2px solid #dee2e6;
|
||||
flex-shrink:0;background:{{ (material.color | css_color) if material and material.color else '#cccccc' }}"></span>
|
||||
<input type="color" id="nativePicker" class="form-control form-control-color p-0 border-0"
|
||||
style="width:36px;height:32px;cursor:pointer"
|
||||
value="{{ (material.color | css_color) if material and material.color else '#cccccc' }}"
|
||||
title="Choisir une couleur personnalisée">
|
||||
<input type="text" id="hexInput" class="form-control form-control-sm font-monospace"
|
||||
style="max-width:110px" placeholder="#rrggbb"
|
||||
value="{{ (material.color | css_color) if material and material.color else '' }}">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="clearColor"
|
||||
title="Aucune couleur">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Prix (€/kg) *</label>
|
||||
@@ -98,3 +121,126 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
// ── Palette de preset filaments ──────────────────────────────────────
|
||||
const PRESETS = [
|
||||
// Blancs / Neutres
|
||||
{ hex: '#ffffff', label: 'Blanc' },
|
||||
{ hex: '#f5f0e8', label: 'Naturel' },
|
||||
{ hex: '#e8e0d0', label: 'Crème' },
|
||||
{ hex: '#d4b896', label: 'Beige' },
|
||||
{ hex: '#9ca3af', label: 'Argent' },
|
||||
{ hex: '#6b7280', label: 'Gris' },
|
||||
{ hex: '#374151', label: 'Gris foncé' },
|
||||
{ hex: '#111111', label: 'Noir' },
|
||||
// Rouges / Roses
|
||||
{ hex: '#fca5a5', label: 'Rose clair' },
|
||||
{ hex: '#f87171', label: 'Saumon' },
|
||||
{ hex: '#ef4444', label: 'Rouge' },
|
||||
{ hex: '#dc2626', label: 'Rouge foncé' },
|
||||
{ hex: '#ec4899', label: 'Rose' },
|
||||
{ hex: '#db2777', label: 'Fuchsia' },
|
||||
// Oranges / Jaunes
|
||||
{ hex: '#fb923c', label: 'Orange clair' },
|
||||
{ hex: '#f97316', label: 'Orange' },
|
||||
{ hex: '#ea580c', label: 'Orange foncé' },
|
||||
{ hex: '#fbbf24', label: 'Jaune' },
|
||||
{ hex: '#d97706', label: 'Or' },
|
||||
{ hex: '#eab308', label: 'Jaune vif' },
|
||||
// Verts
|
||||
{ hex: '#86efac', label: 'Vert clair' },
|
||||
{ hex: '#4ade80', label: 'Vert lime' },
|
||||
{ hex: '#22c55e', label: 'Vert' },
|
||||
{ hex: '#16a34a', label: 'Vert foncé' },
|
||||
{ hex: '#15803d', label: 'Forêt' },
|
||||
{ hex: '#65a30d', label: 'Olive' },
|
||||
// Bleus / Cyans
|
||||
{ hex: '#7dd3fc', label: 'Bleu ciel' },
|
||||
{ hex: '#38bdf8', label: 'Ciel' },
|
||||
{ hex: '#06b6d4', label: 'Cyan' },
|
||||
{ hex: '#3b82f6', label: 'Bleu' },
|
||||
{ hex: '#2563eb', label: 'Bleu vif' },
|
||||
{ hex: '#1d4ed8', label: 'Bleu foncé' },
|
||||
{ hex: '#1e3a8a', label: 'Marine' },
|
||||
// Violets / Mauves
|
||||
{ hex: '#c4b5fd', label: 'Lavande' },
|
||||
{ hex: '#a78bfa', label: 'Mauve' },
|
||||
{ hex: '#8b5cf6', label: 'Violet' },
|
||||
{ hex: '#7c3aed', label: 'Violet foncé' },
|
||||
{ hex: '#6366f1', label: 'Indigo' },
|
||||
{ hex: '#d946ef', label: 'Magenta' },
|
||||
// Marrons
|
||||
{ hex: '#b45309', label: 'Marron clair' },
|
||||
{ hex: '#92400e', label: 'Marron' },
|
||||
{ hex: '#78350f', label: 'Marron foncé' },
|
||||
// Spéciaux
|
||||
{ hex: 'rgba(200,200,200,0.3)', label: 'Translucide', css: 'repeating-linear-gradient(45deg,#ccc 0,#ccc 3px,#fff 3px,#fff 8px)' },
|
||||
];
|
||||
|
||||
const colorValue = document.getElementById('colorValue');
|
||||
const colorPreview = document.getElementById('colorPreview');
|
||||
const nativePicker = document.getElementById('nativePicker');
|
||||
const hexInput = document.getElementById('hexInput');
|
||||
const swatchGrid = document.getElementById('swatchGrid');
|
||||
|
||||
// ── Construire les swatches ──────────────────────────────────────────
|
||||
PRESETS.forEach(p => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.title = p.label;
|
||||
btn.dataset.hex = p.hex;
|
||||
btn.style.cssText = [
|
||||
'width:24px', 'height:24px', 'border-radius:5px', 'border:2px solid transparent',
|
||||
`background:${p.css || p.hex}`, 'cursor:pointer', 'padding:0', 'flex-shrink:0',
|
||||
'transition:transform .1s,border-color .1s'
|
||||
].join(';');
|
||||
btn.addEventListener('mouseenter', () => { btn.style.transform = 'scale(1.3)'; });
|
||||
btn.addEventListener('mouseleave', () => { if (btn.dataset.hex !== colorValue.value) btn.style.transform = ''; });
|
||||
btn.addEventListener('click', () => setColor(p.hex));
|
||||
swatchGrid.appendChild(btn);
|
||||
});
|
||||
|
||||
// ── Fonction centrale ────────────────────────────────────────────────
|
||||
function setColor(hex) {
|
||||
colorValue.value = hex;
|
||||
hexInput.value = hex;
|
||||
colorPreview.style.background = hex;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(hex)) nativePicker.value = hex;
|
||||
// Highlight swatch actif
|
||||
swatchGrid.querySelectorAll('button').forEach(b => {
|
||||
const active = b.dataset.hex === hex;
|
||||
b.style.borderColor = active ? '#ff6b35' : 'transparent';
|
||||
b.style.transform = active ? 'scale(1.3)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// ── Native color picker ──────────────────────────────────────────────
|
||||
nativePicker.addEventListener('input', () => setColor(nativePicker.value));
|
||||
|
||||
// ── Saisie hex manuelle ──────────────────────────────────────────────
|
||||
hexInput.addEventListener('input', function () {
|
||||
const v = this.value.trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) setColor(v);
|
||||
else { colorValue.value = v; colorPreview.style.background = v; }
|
||||
});
|
||||
|
||||
// ── Effacer ──────────────────────────────────────────────────────────
|
||||
document.getElementById('clearColor').addEventListener('click', () => {
|
||||
colorValue.value = '';
|
||||
hexInput.value = '';
|
||||
colorPreview.style.background = '#e5e7eb';
|
||||
swatchGrid.querySelectorAll('button').forEach(b => {
|
||||
b.style.borderColor = 'transparent';
|
||||
b.style.transform = '';
|
||||
});
|
||||
});
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────────────
|
||||
const init = colorValue.value;
|
||||
if (init) setColor(init);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+244
-13
@@ -4,31 +4,103 @@
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h1><i class="bi bi-boxes me-2" style="color:#8b5cf6"></i>Matières & Stock</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#filterBar" aria-expanded="false" id="filterToggle">
|
||||
<i class="bi bi-funnel me-1"></i>Filtres
|
||||
</button>
|
||||
<a href="{{ url_for('new_material') }}" class="btn btn-sm" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ajouter une matière
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de filtres collapsable -->
|
||||
<div class="collapse mb-3" id="filterBar">
|
||||
<div class="card card-body py-3">
|
||||
<div class="row g-3 align-items-end">
|
||||
<!-- Recherche texte -->
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small text-muted mb-1">Rechercher</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="Nom, marque…">
|
||||
<button class="btn btn-outline-secondary" type="button" id="clearSearch" title="Effacer">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filtre par type -->
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small text-muted mb-1">Type</label>
|
||||
<div class="d-flex flex-wrap gap-1" id="typeFilters">
|
||||
<button class="btn btn-sm btn-secondary active" data-type="all">Tous</button>
|
||||
{% for t in types %}
|
||||
<button class="btn btn-sm btn-outline-secondary" data-type="{{ t }}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tri -->
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Trier par</label>
|
||||
<select id="sortSelect" class="form-select form-select-sm">
|
||||
<option value="type-name">Type puis nom</option>
|
||||
<option value="name">Nom (A→Z)</option>
|
||||
<option value="price-asc">Prix/kg (croissant)</option>
|
||||
<option value="price-desc">Prix/kg (décroissant)</option>
|
||||
<option value="stock-asc">Stock (croissant)</option>
|
||||
<option value="stock-desc">Stock (décroissant)</option>
|
||||
<option value="usage-desc">Plus utilisé</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Résumé du filtre actif -->
|
||||
<div class="mt-2 small text-muted" id="filterSummary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
{% if materials %}
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<table class="table table-hover mb-0 align-middle" id="materialsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nom</th><th>Type</th><th>Couleur</th>
|
||||
<th>Prix/kg</th><th>Stock</th><th>Commandes</th><th>Utilisé total</th><th></th>
|
||||
<th data-col="name" class="sortable-th" style="cursor:pointer">
|
||||
Nom <i class="bi bi-arrow-down-up text-muted small"></i>
|
||||
</th>
|
||||
<th data-col="type" class="sortable-th" style="cursor:pointer">
|
||||
Type <i class="bi bi-arrow-down-up text-muted small"></i>
|
||||
</th>
|
||||
<th>Couleur</th>
|
||||
<th data-col="price" class="sortable-th" style="cursor:pointer">
|
||||
Prix/kg <i class="bi bi-arrow-down-up text-muted small"></i>
|
||||
</th>
|
||||
<th data-col="stock" class="sortable-th" style="cursor:pointer">
|
||||
Stock <i class="bi bi-arrow-down-up text-muted small"></i>
|
||||
</th>
|
||||
<th>Commandes</th>
|
||||
<th data-col="usage" class="sortable-th" style="cursor:pointer">
|
||||
Utilisé total <i class="bi bi-arrow-down-up text-muted small"></i>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="materialsBody">
|
||||
{% for m in materials %}
|
||||
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
|
||||
<tr>
|
||||
<tr class="mat-row"
|
||||
data-name="{{ m.name|lower }}{% if m.brand %} {{ m.brand|lower }}{% endif %}"
|
||||
data-type="{{ m.type }}"
|
||||
data-price="{{ m.price_per_kg }}"
|
||||
data-stock="{{ m.stock_g }}"
|
||||
data-usage="{{ m.total_used_g }}"
|
||||
data-jobs="{{ m.job_count }}">
|
||||
<td class="fw-semibold">{{ m.name }}{% if m.brand %} <small class="text-muted">/ {{ m.brand }}</small>{% endif %}</td>
|
||||
<td><span class="badge bg-secondary">{{ m.type }}</span></td>
|
||||
<td>
|
||||
{% if m.color %}
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
<span style="width:14px;height:14px;border-radius:50%;background:{{ m.color }};border:1px solid #ccc;display:inline-block"></span>
|
||||
<span style="width:14px;height:14px;border-radius:50%;background:{{ m.color | css_color }};border:1px solid #ccc;display:inline-block"></span>
|
||||
{{ m.color }}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
@@ -49,24 +121,29 @@
|
||||
{% if m.stock_g <= 0 %}bg-danger
|
||||
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
|
||||
{% else %}bg-success{% endif %}"
|
||||
style="width:{{ [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min }}%">
|
||||
style="width:{{ stock_pct }}%">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ m.job_count }}</td>
|
||||
<td>{{ m.total_used_g }} g</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<div class="d-flex gap-1 flex-nowrap">
|
||||
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
|
||||
data-bs-target="#restockModal{{ m.id }}">
|
||||
<i class="bi bi-plus-lg"></i> Stock
|
||||
data-bs-target="#restockModal{{ m.id }}" title="Réapprovisionner">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
<a href="{{ url_for('edit_material', id=m.id) }}" class="btn btn-sm btn-outline-secondary py-0">
|
||||
<a href="{{ url_for('edit_material', id=m.id) }}" class="btn btn-sm btn-outline-secondary py-0" title="Modifier">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('duplicate_material', id=m.id) }}" style="display:inline">
|
||||
<button class="btn btn-sm btn-outline-secondary py-0" title="Dupliquer">
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('delete_material', id=m.id) }}"
|
||||
onsubmit="return confirm('Supprimer cette matière ?')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
||||
onsubmit="return confirm('Supprimer {{ m.name }} ?')">
|
||||
<button class="btn btn-sm btn-outline-danger py-0" title="Supprimer"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
@@ -106,6 +183,12 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Ligne "aucun résultat" -->
|
||||
<div id="noResults" class="text-center text-muted py-4" style="display:none">
|
||||
<i class="bi bi-search fs-3 d-block mb-2"></i>
|
||||
Aucune matière ne correspond aux filtres.
|
||||
<button class="btn btn-sm btn-link p-0 ms-1" id="resetFilters">Réinitialiser</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-boxes fs-1"></i>
|
||||
@@ -118,3 +201,151 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
const rows = Array.from(document.querySelectorAll('.mat-row'));
|
||||
if (!rows.length) return;
|
||||
|
||||
let activeType = 'all';
|
||||
let searchText = '';
|
||||
let sortKey = 'type-name';
|
||||
|
||||
const tbody = document.getElementById('materialsBody');
|
||||
const noResults = document.getElementById('noResults');
|
||||
const summary = document.getElementById('filterSummary');
|
||||
const filterBar = document.getElementById('filterBar');
|
||||
const toggle = document.getElementById('filterToggle');
|
||||
|
||||
// Persist filter bar open/close state
|
||||
const STORE_KEY = 'mat_filter_open';
|
||||
if (localStorage.getItem(STORE_KEY) === '1') {
|
||||
filterBar.classList.add('show');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
toggle.classList.add('active');
|
||||
}
|
||||
filterBar.addEventListener('shown.bs.collapse', () => {
|
||||
localStorage.setItem(STORE_KEY, '1');
|
||||
toggle.classList.add('active');
|
||||
});
|
||||
filterBar.addEventListener('hidden.bs.collapse', () => {
|
||||
localStorage.setItem(STORE_KEY, '0');
|
||||
toggle.classList.remove('active');
|
||||
});
|
||||
|
||||
// ── Search ──────────────────────────────────
|
||||
document.getElementById('searchInput').addEventListener('input', function() {
|
||||
searchText = this.value.toLowerCase().trim();
|
||||
render();
|
||||
});
|
||||
document.getElementById('clearSearch').addEventListener('click', function() {
|
||||
document.getElementById('searchInput').value = '';
|
||||
searchText = '';
|
||||
render();
|
||||
});
|
||||
|
||||
// ── Type filters ────────────────────────────
|
||||
document.querySelectorAll('#typeFilters button').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('#typeFilters button').forEach(b => {
|
||||
b.classList.remove('active', 'btn-secondary');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
});
|
||||
this.classList.add('active', 'btn-secondary');
|
||||
this.classList.remove('btn-outline-secondary');
|
||||
activeType = this.dataset.type;
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sort select ─────────────────────────────
|
||||
document.getElementById('sortSelect').addEventListener('change', function() {
|
||||
sortKey = this.value;
|
||||
render();
|
||||
});
|
||||
|
||||
// ── Column header click sorts ───────────────
|
||||
document.querySelectorAll('.sortable-th').forEach(th => {
|
||||
th.addEventListener('click', function() {
|
||||
const col = this.dataset.col;
|
||||
const map = {
|
||||
name: 'name',
|
||||
type: 'type-name',
|
||||
price: 'price-asc',
|
||||
stock: 'stock-desc',
|
||||
usage: 'usage-desc'
|
||||
};
|
||||
if (map[col]) {
|
||||
document.getElementById('sortSelect').value = map[col];
|
||||
sortKey = map[col];
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reset ───────────────────────────────────
|
||||
const resetBtn = document.getElementById('resetFilters');
|
||||
if (resetBtn) resetBtn.addEventListener('click', reset);
|
||||
|
||||
function reset() {
|
||||
searchText = '';
|
||||
activeType = 'all';
|
||||
sortKey = 'type-name';
|
||||
document.getElementById('searchInput').value = '';
|
||||
document.getElementById('sortSelect').value = 'type-name';
|
||||
document.querySelectorAll('#typeFilters button').forEach(b => {
|
||||
b.classList.remove('active', 'btn-secondary');
|
||||
b.classList.add('btn-outline-secondary');
|
||||
});
|
||||
const allBtn = document.querySelector('#typeFilters button[data-type="all"]');
|
||||
allBtn.classList.add('active', 'btn-secondary');
|
||||
allBtn.classList.remove('btn-outline-secondary');
|
||||
render();
|
||||
}
|
||||
|
||||
// ── Main render ─────────────────────────────
|
||||
function render() {
|
||||
// 1. Filter
|
||||
const visible = rows.filter(r => {
|
||||
const matchType = activeType === 'all' || r.dataset.type === activeType;
|
||||
const matchSearch = !searchText || r.dataset.name.includes(searchText);
|
||||
return matchType && matchSearch;
|
||||
});
|
||||
|
||||
// 2. Sort
|
||||
visible.sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'name': return a.dataset.name.localeCompare(b.dataset.name);
|
||||
case 'type-name': return a.dataset.type.localeCompare(b.dataset.type)
|
||||
|| a.dataset.name.localeCompare(b.dataset.name);
|
||||
case 'price-asc': return parseFloat(a.dataset.price) - parseFloat(b.dataset.price);
|
||||
case 'price-desc': return parseFloat(b.dataset.price) - parseFloat(a.dataset.price);
|
||||
case 'stock-asc': return parseFloat(a.dataset.stock) - parseFloat(b.dataset.stock);
|
||||
case 'stock-desc': return parseFloat(b.dataset.stock) - parseFloat(a.dataset.stock);
|
||||
case 'usage-desc': return parseFloat(b.dataset.usage) - parseFloat(a.dataset.usage);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Reorder DOM & show/hide
|
||||
const hidden = rows.filter(r => !visible.includes(r));
|
||||
hidden.forEach(r => { r.style.display = 'none'; });
|
||||
visible.forEach(r => { r.style.display = ''; tbody.appendChild(r); });
|
||||
|
||||
// 4. No results message
|
||||
noResults.style.display = visible.length === 0 ? '' : 'none';
|
||||
|
||||
// 5. Active filter summary
|
||||
const parts = [];
|
||||
if (activeType !== 'all') parts.push(`Type : <strong>${activeType}</strong>`);
|
||||
if (searchText) parts.push(`Recherche : <strong>"${searchText}"</strong>`);
|
||||
summary.innerHTML = parts.length
|
||||
? `${visible.length} résultat${visible.length !== 1 ? 's' : ''} — ${parts.join(' · ')}`
|
||||
: '';
|
||||
}
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<title>3D Pricing — Mobile</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f4f6f9; padding-bottom: 2rem; }
|
||||
.topbar {
|
||||
background: #1a1d23; color: #fff; padding: .75rem 1rem;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.topbar .brand { font-weight: 700; font-size: 1.1rem; }
|
||||
.topbar .brand span { color: #ff6b35; }
|
||||
.section-title {
|
||||
font-size: .7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: #6b7280; padding: 1rem 1rem .4rem;
|
||||
}
|
||||
.slot-card {
|
||||
background: #fff; border-radius: 10px; padding: .9rem 1rem;
|
||||
margin: .4rem .75rem; box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
}
|
||||
.slot-card .job-name { font-weight: 600; font-size: .95rem; }
|
||||
.slot-card .meta { font-size: .78rem; color: #6b7280; }
|
||||
.slot-card .time { font-size: .82rem; color: #374151; }
|
||||
.tab-bar {
|
||||
display: flex; background: #fff; border-bottom: 2px solid #f0f0f0;
|
||||
position: sticky; top: 53px; z-index: 99;
|
||||
}
|
||||
.tab-bar button {
|
||||
flex: 1; border: none; background: none; padding: .7rem .5rem;
|
||||
font-size: .8rem; color: #6b7280; font-weight: 600;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -2px;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.tab-bar button.active { color: #ff6b35; border-bottom-color: #ff6b35; }
|
||||
.tab-pane { display: none; }
|
||||
.tab-pane.active { display: block; }
|
||||
.form-card { background: #fff; border-radius: 10px; margin: .75rem; padding: 1rem; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
|
||||
.btn-primary-orange { background: #ff6b35; color: #fff; border: none; border-radius: 8px; }
|
||||
.btn-primary-orange:hover { background: #e85d2d; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="brand"><i class="bi bi-layers-half me-1"></i>3D<span>Pricing</span></div>
|
||||
<a href="/" class="btn btn-sm btn-outline-light btn-sm">
|
||||
<i class="bi bi-grid me-1"></i>Bureau
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tab-bar">
|
||||
<button class="active" onclick="showTab('planning', this)">
|
||||
<i class="bi bi-calendar3 d-block mb-1"></i>Planning
|
||||
</button>
|
||||
<button onclick="showTab('done', this)">
|
||||
<i class="bi bi-check-circle d-block mb-1"></i>Terminé
|
||||
</button>
|
||||
<button onclick="showTab('block', this)">
|
||||
<i class="bi bi-calendar-x d-block mb-1"></i>Indispo
|
||||
</button>
|
||||
<button onclick="showTab('shift', this)">
|
||||
<i class="bi bi-arrow-left-right d-block mb-1"></i>Décaler
|
||||
</button>
|
||||
<button onclick="showTab('fail', this)">
|
||||
<i class="bi bi-exclamation-triangle d-block mb-1"></i>Échec
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ cat }} mx-3 mt-2 mb-0 py-2 small">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- ── Tab : Planning ─────────────────────────────────── -->
|
||||
<div class="tab-pane active" id="tab-planning">
|
||||
<div class="section-title">Prochaines impressions</div>
|
||||
{% if upcoming %}
|
||||
{% for s in upcoming %}
|
||||
<div class="slot-card">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="job-name">{{ s.job_name }}</div>
|
||||
<div class="meta">
|
||||
<i class="bi bi-printer me-1"></i>{{ s.printer_name }}
|
||||
{% if s.client_name %} · {{ s.client_name }}{% endif %}
|
||||
</div>
|
||||
<div class="time mt-1">
|
||||
<i class="bi bi-clock me-1"></i>
|
||||
{{ s.planned_start[:16].replace('T',' ') }} →
|
||||
{{ s.planned_end[11:16] }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge {% if s.status=='done' %}bg-success{% elif s.status=='failed' %}bg-danger{% else %}bg-secondary{% endif %}">
|
||||
{{ s.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-check fs-1 d-block mb-2"></i>
|
||||
Aucune impression planifiée.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Indisponibilité ──────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-block">
|
||||
<div class="section-title">Ajouter une indisponibilité</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="block">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Date</label>
|
||||
<input type="date" name="date" class="form-control" value="{{ today }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Type</label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="off">🔴 Jour off (toutes machines)</option>
|
||||
<option value="maintenance">🔧 Maintenance</option>
|
||||
<option value="other">📌 Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Machine (optionnel)</label>
|
||||
<select name="printer_id" class="form-select">
|
||||
<option value="">Toutes les machines</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="Raison…">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary-orange w-100">
|
||||
<i class="bi bi-calendar-plus me-1"></i>Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Décaler ──────────────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-shift">
|
||||
<div class="section-title">Décaler un créneau</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="shift">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Créneau à décaler</label>
|
||||
<select name="slot_id" class="form-select" required>
|
||||
<option value="">Choisir…</option>
|
||||
{% for s in upcoming %}
|
||||
<option value="{{ s.id }}">
|
||||
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nouveau début</label>
|
||||
<input type="datetime-local" name="new_start" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary-orange w-100">
|
||||
<i class="bi bi-arrow-left-right me-1"></i>Décaler
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Terminé ─────────────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-done">
|
||||
<div class="section-title">Marquer une impression comme terminée</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="done">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Créneau terminé</label>
|
||||
<select name="slot_id" class="form-select" required>
|
||||
<option value="">Choisir…</option>
|
||||
{% for s in upcoming %}
|
||||
<option value="{{ s.id }}">
|
||||
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Poids réel consommé (g) <small class="text-muted fw-normal">optionnel</small></label>
|
||||
<input type="number" name="actual_g" class="form-control" step="0.1" min="0"
|
||||
placeholder="ex: 48.5 — pour déduire du stock">
|
||||
<div class="form-text">Laissez vide si vous ne connaissez pas le poids exact.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn w-100" style="background:#22c55e;color:#fff">
|
||||
<i class="bi bi-check-circle me-1"></i>Marquer comme terminé
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab : Échec ────────────────────────────────────── -->
|
||||
<div class="tab-pane" id="tab-fail">
|
||||
<div class="section-title">Signaler un échec d'impression</div>
|
||||
<div class="form-card">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="fail">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Créneau concerné</label>
|
||||
<select name="slot_id" class="form-select" required>
|
||||
<option value="">Choisir…</option>
|
||||
{% for s in upcoming %}
|
||||
<option value="{{ s.id }}">
|
||||
{{ s.job_name }} — {{ s.planned_start[:16].replace('T',' ') }} [{{ s.printer_name }}]
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Description du problème</label>
|
||||
<textarea name="failure_note" class="form-control" rows="2"
|
||||
placeholder="Détachement, sous-extrusion, bourrage…"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Filament gaspillé (g) <small class="text-muted fw-normal">optionnel</small></label>
|
||||
<input type="number" name="wasted_g" class="form-control" step="0.1" min="0"
|
||||
placeholder="Poids plateau si vide">
|
||||
<div class="form-text">Si vide, le poids du plateau entier est déduit du stock.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Signaler l'échec
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function showTab(name, btn) {
|
||||
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-bar button').forEach(b => b.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
btn.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+435
-40
@@ -9,21 +9,34 @@
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
|
||||
<!-- Import 3mf -->
|
||||
<!-- Import 3mf (collapsible) -->
|
||||
<div class="card mb-3 border border-warning border-opacity-50">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-file-earmark-zip text-warning me-2"></i>Import Bambu Studio (.3mf)
|
||||
<span class="badge bg-warning text-dark ms-1">Optionnel</span>
|
||||
<div class="card-header py-2 d-flex align-items-center" role="button"
|
||||
id="bambuToggle" style="cursor:pointer;user-select:none"
|
||||
onclick="toggleBambu()">
|
||||
<i class="bi bi-file-earmark-zip text-warning me-2"></i>
|
||||
<span class="fw-semibold">Import Bambu Studio (.3mf)</span>
|
||||
<span class="badge bg-warning text-dark ms-2">Optionnel</span>
|
||||
<i class="bi bi-chevron-down ms-auto" id="bambuChevron"
|
||||
style="transition:transform .2s"></i>
|
||||
</div>
|
||||
<div class="collapse" id="bambuBody">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<input type="file" id="file3mf" accept=".3mf" class="form-control" style="max-width:320px">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<input type="file" id="file3mf" accept=".3mf,.gcode.3mf" class="form-control" style="max-width:260px">
|
||||
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
|
||||
<i class="bi bi-magic me-1"></i>Extraire
|
||||
</button>
|
||||
<span class="text-muted small">ou</span>
|
||||
<button type="button" class="btn btn-outline-secondary" id="ncBambuBtn"
|
||||
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
||||
onclick="ncPickerMode='bambu'">
|
||||
<i class="bi bi-cloud-arrow-down me-1"></i>Nextcloud
|
||||
</button>
|
||||
<span id="parseStatus" class="text-muted small"></span>
|
||||
</div>
|
||||
<div class="form-text mt-1">Votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.</div>
|
||||
<div class="form-text mt-1">Choisissez un fichier <strong>.3mf</strong> local ou depuis Nextcloud — poids et durée seront remplis automatiquement. <strong class="text-warning"><i class="bi bi-exclamation-triangle-fill me-1"></i>Le fichier doit être un <code>gcode.3mf</code> (exporté depuis Bambu Studio après slicing), pas un simple <code>.3mf</code> de conception.</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,35 +47,71 @@
|
||||
<div class="card-header py-3">Donnees impression</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
{% if prefill %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info py-2 mb-0 small">
|
||||
<i class="bi bi-copy me-1"></i>
|
||||
Dupliqué depuis <strong>{{ prefill.name }}</strong> — modifiez les champs souhaités.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">Nom de la commande</label>
|
||||
<input type="text" name="name" id="jobName" class="form-control" required
|
||||
placeholder="ex: Marqueurs competition salle X">
|
||||
placeholder="ex: Marqueurs competition salle X"
|
||||
value="{% if prefill %}Copie — {{ prefill.name }}{% endif %}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Fichier source</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
||||
placeholder="nom_fichier.3mf">
|
||||
placeholder="nom_fichier.3mf"
|
||||
value="{{ prefill.source_file if prefill else '' }}">
|
||||
<input type="hidden" name="source_nc_path" id="sourceNcPath"
|
||||
value="{{ prefill.source_nc_path if prefill else '' }}">
|
||||
<button type="button" class="btn btn-outline-secondary" id="ncBrowseBtn"
|
||||
data-bs-toggle="modal" data-bs-target="#ncPickerModal"
|
||||
title="Parcourir Nextcloud"
|
||||
onclick="ncPickerMode='source'">
|
||||
<i class="bi bi-cloud-arrow-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Client</label>
|
||||
<select name="client_id" id="clientSelect" class="form-select">
|
||||
<option value="">Sans client</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" data-dm="{{ c.design_multiplier }}">{{ c.name }}</option>
|
||||
<option value="{{ c.id }}" data-dm="{{ c.design_multiplier }}"
|
||||
{% if prefill and prefill.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Projet</label>
|
||||
<select name="project_id" id="projectSelect" class="form-select">
|
||||
<option value="">— Sans projet —</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}"
|
||||
{% if (prefill and prefill.project_id == p.id) or request.args.get('project_id')|int == p.id %}selected{% endif %}>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Matiere (filament)</label>
|
||||
<select name="material_id" id="materialSelect" class="form-select">
|
||||
<option value="">Prix global (parametres)</option>
|
||||
{% for m in materials %}
|
||||
<option value="{{ m.id }}" data-price="{{ m.price_per_kg }}"
|
||||
data-stock="{{ m.stock_g }}" data-threshold="{{ m.low_stock_threshold_g }}"
|
||||
data-name="{{ m.name }} ({{ m.type }})">
|
||||
data-name="{{ m.name }} ({{ m.type }})"
|
||||
{% if prefill and prefill.material_id == m.id %}selected{% endif %}>
|
||||
{{ m.name }} ({{ m.type }}) — {{ m.price_per_kg }} €/kg
|
||||
{% if m.stock_g <= 0 %}[RUPTURE]{% elif m.stock_g <= m.low_stock_threshold_g %}[stock bas]{% endif %}
|
||||
</option>
|
||||
@@ -75,16 +124,19 @@
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Poids plateau (g)</label>
|
||||
<input type="number" name="weight_g" id="weight_g" class="form-control"
|
||||
step="0.01" min="0.1" required placeholder="423.97">
|
||||
step="0.01" min="0.1" required placeholder="423.97"
|
||||
value="{{ prefill.weight_g if prefill else '' }}">
|
||||
<div class="form-text">Poids total du plateau Bambu (toutes pièces)</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Heures</label>
|
||||
<input type="number" name="hours" id="hours" class="form-control" min="0" value="0">
|
||||
<input type="number" name="hours" id="hours" class="form-control" min="0"
|
||||
value="{{ prefill.hours if prefill else 0 }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Minutes</label>
|
||||
<input type="number" name="minutes" id="minutes" class="form-control" min="0" max="59" value="0">
|
||||
<input type="number" name="minutes" id="minutes" class="form-control" min="0" max="59"
|
||||
value="{{ prefill.minutes if prefill else 0 }}">
|
||||
<div class="form-text">Temps total Bambu</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,16 +144,25 @@
|
||||
<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">
|
||||
min="1" step="1" value="{{ prefill.pieces_per_plate if prefill else 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">
|
||||
min="1" step="1" value="{{ prefill.order_qty if prefill else 1 }}">
|
||||
<div class="form-text">Nombre total de pièces à produire.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-0">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Nom / référence du plateau <span class="text-muted fw-normal small">(optionnel)</span></label>
|
||||
<input type="text" name="plate_name" id="plate_name" class="form-control"
|
||||
placeholder="ex : «Plateau 2» ou nom custom Bambu Studio"
|
||||
value="{{ prefill.plate_name if prefill else '' }}">
|
||||
<div id="plateObjectsHint" class="form-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +183,10 @@
|
||||
<select name="machine_profile_id" id="machineProfileSelect" class="form-select form-select-sm">
|
||||
<option value="">— Parametres globaux —</option>
|
||||
{% for p in machine_profiles %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
<option value="{{ p.id }}"
|
||||
{% if prefill and prefill.machine_profile_id == p.id %}selected{% endif %}>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -133,7 +197,10 @@
|
||||
<select name="handling_profile_id" id="handlingProfileSelect" class="form-select form-select-sm">
|
||||
<option value="">— Parametres globaux —</option>
|
||||
{% for p in handling_profiles %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
<option value="{{ p.id }}"
|
||||
{% if prefill and prefill.handling_profile_id == p.id %}selected{% endif %}>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -144,7 +211,8 @@
|
||||
<select name="material_profile_id" id="materialProfileSelect" class="form-select form-select-sm">
|
||||
<option value="">— Parametres globaux —</option>
|
||||
{% for p in material_profiles %}
|
||||
<option value="{{ p.id }}" data-margin="{{ p.material_margin_pct }}">
|
||||
<option value="{{ p.id }}" data-margin="{{ p.material_margin_pct }}"
|
||||
{% if prefill and prefill.material_profile_id == p.id %}selected{% endif %}>
|
||||
{{ p.name }} ({{ p.material_margin_pct }}%)
|
||||
</option>
|
||||
{% endfor %}
|
||||
@@ -159,7 +227,8 @@
|
||||
{% for p in pricing_profiles %}
|
||||
<option value="{{ p.id }}"
|
||||
data-dm="{{ p.design_multiplier }}"
|
||||
data-margin="{{ p.gross_margin_pct }}">
|
||||
data-margin="{{ p.gross_margin_pct }}"
|
||||
{% if prefill and prefill.pricing_profile_id == p.id %}selected{% endif %}>
|
||||
{{ p.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
@@ -186,7 +255,8 @@
|
||||
</button>
|
||||
</div>
|
||||
<input type="number" name="design_multiplier" id="design_multiplier"
|
||||
class="form-control form-control-sm" step="0.05" min="0" value="1.80">
|
||||
class="form-control form-control-sm" step="0.05" min="0"
|
||||
value="{{ prefill.design_multiplier if prefill else 1.80 }}">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold d-flex justify-content-between">
|
||||
@@ -199,7 +269,8 @@
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setMargin(35)">35%</button>
|
||||
</div>
|
||||
<input type="range" name="gross_margin_pct" id="gross_margin_pct" class="form-range"
|
||||
min="0" max="100" step="0.5" value="27.5">
|
||||
min="0" max="100" step="0.5"
|
||||
value="{{ prefill.gross_margin_pct if prefill else 27.5 }}">
|
||||
<div class="d-flex justify-content-between" style="font-size:.75rem;color:#6b7280">
|
||||
<span>0%</span><span>100%</span>
|
||||
</div>
|
||||
@@ -207,18 +278,24 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Remise client (%)</label>
|
||||
<input type="number" name="discount_pct" id="discount_pct" class="form-control"
|
||||
min="0" max="100" step="0.5" value="0">
|
||||
min="0" max="100" step="0.5"
|
||||
value="{{ prefill.discount_pct if prefill else 0 }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes internes</label>
|
||||
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ prefill.notes if prefill else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn w-100 py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" name="action" value="save" class="btn flex-fill py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-2"></i>Enregistrer la commande
|
||||
</button>
|
||||
<button type="submit" name="action" value="save_and_new" class="btn py-2 fw-semibold btn-outline-secondary" style="white-space:nowrap">
|
||||
<i class="bi bi-plus-square me-1"></i>Enregistrer et créer un autre
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -237,35 +314,164 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modale Nextcloud File Picker -->
|
||||
<div class="modal fade" id="ncPickerModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header pb-2">
|
||||
<div class="flex-fill">
|
||||
<h5 class="modal-title mb-1"><i class="bi bi-cloud me-2"></i>Nextcloud — Choisir un fichier</h5>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-2 small" id="ncBreadcrumb"></ol>
|
||||
</nav>
|
||||
<input type="text" id="ncSearchInput" class="form-control form-control-sm"
|
||||
placeholder="Filtrer par nom…" autocomplete="off">
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-2 align-self-start mt-1" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div id="ncBambuHint" class="alert alert-info d-none m-2 mb-0 py-2 px-3 small" role="alert">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Sélectionnez le fichier <code>.gcode.3mf</code> pour importer poids, durée et données de découpe.
|
||||
</div>
|
||||
<div class="modal-body p-0" id="ncPickerBody" style="max-height:55vh;overflow-y:auto">
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-cloud fs-1 d-block mb-2"></i>
|
||||
Cliquez sur le bouton <i class="bi bi-cloud-arrow-down"></i> pour ouvrir.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<small class="text-muted me-auto">Cliquez sur un fichier pour le sélectionner.</small>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ── 3MF parser ────────────────────────────────────────────────────────────────
|
||||
// ── Section Bambu collapsible ────────────────────────────────────────────────
|
||||
(function () {
|
||||
const KEY = 'bambu_open';
|
||||
const body = document.getElementById('bambuBody');
|
||||
const chevron = document.getElementById('bambuChevron');
|
||||
const isOpen = localStorage.getItem(KEY) === 'true'; // collapsed by default
|
||||
if (isOpen) {
|
||||
body.classList.add('show');
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
})();
|
||||
|
||||
function toggleBambu() {
|
||||
const KEY = 'bambu_open';
|
||||
const body = document.getElementById('bambuBody');
|
||||
const chevron = document.getElementById('bambuChevron');
|
||||
const opening = !body.classList.contains('show');
|
||||
body.classList.toggle('show', opening);
|
||||
chevron.style.transform = opening ? 'rotate(180deg)' : '';
|
||||
localStorage.setItem(KEY, opening);
|
||||
}
|
||||
|
||||
// ── 3MF parser avec sélecteur de plaque ──────────────────────────────────────
|
||||
let _3mfPlates = [];
|
||||
|
||||
function parse3mf() {
|
||||
const file = document.getElementById('file3mf').files[0];
|
||||
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
||||
const status = document.getElementById('parseStatus');
|
||||
status.textContent = 'Analyse...';
|
||||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyse…';
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
let filled = [];
|
||||
if (d.weight_g) { document.getElementById('weight_g').value = d.weight_g; filled.push('poids'); }
|
||||
if (d.hours != null) { document.getElementById('hours').value = d.hours; filled.push('heures'); }
|
||||
if (d.minutes != null){ document.getElementById('minutes').value = d.minutes; }
|
||||
if (d.filename) {
|
||||
document.getElementById('sourceFile').value = d.filename;
|
||||
if (!document.getElementById('jobName').value)
|
||||
document.getElementById('jobName').value = d.filename;
|
||||
}
|
||||
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
|
||||
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
|
||||
recalc();
|
||||
// ── Fichier non slicé ──
|
||||
if (d.warning) {
|
||||
status.innerHTML = `<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>${d.warning}</span>`;
|
||||
return;
|
||||
}
|
||||
// ── Cas Bambu avec plaques détectées ──
|
||||
if (d.plates && d.plates.length > 1) {
|
||||
_3mfPlates = d.plates;
|
||||
renderPlateSelector(d.plates);
|
||||
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) détecté(s) — choisissez ci-dessous</span>`;
|
||||
return;
|
||||
}
|
||||
// ── Plaque unique ou fallback ──
|
||||
const p0 = (d.plates && d.plates.length === 1) ? d.plates[0] : null;
|
||||
applyPlateData(d.weight_g, d.hours, d.minutes,
|
||||
p0 ? p0.pieces : null,
|
||||
p0 ? (p0.name || `Plateau ${p0.index}`) : null,
|
||||
p0 ? p0.object_names : null);
|
||||
const ok = d.weight_g || d.hours != null;
|
||||
status.innerHTML = ok
|
||||
? '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>'
|
||||
: '<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>Données non trouvées — vérifiez que le fichier a bien été slicé</span>';
|
||||
})
|
||||
.catch(() => { status.textContent = 'Erreur de lecture'; status.style.color = '#ef4444'; });
|
||||
.catch(() => { status.innerHTML = '<span class="text-danger">Erreur de lecture</span>'; });
|
||||
}
|
||||
|
||||
function applyPlateData(weight_g, hours, minutes, pieces, plate_name, object_names) {
|
||||
if (weight_g != null) document.getElementById('weight_g').value = weight_g;
|
||||
if (hours != null) document.getElementById('hours').value = hours;
|
||||
if (minutes != null) document.getElementById('minutes').value = minutes;
|
||||
if (pieces != null) document.getElementById('pieces_per_plate').value = pieces;
|
||||
if (plate_name != null) document.getElementById('plate_name').value = plate_name;
|
||||
// Afficher les noms des modèles comme aide
|
||||
const hint = document.getElementById('plateObjectsHint');
|
||||
if (hint && object_names && object_names.length) {
|
||||
hint.innerHTML = `<i class="bi bi-layers me-1 text-muted"></i>Modèles : <span class="text-muted">${object_names.join(', ')}</span>`;
|
||||
} else if (hint) {
|
||||
hint.textContent = '';
|
||||
}
|
||||
recalc();
|
||||
}
|
||||
|
||||
function renderPlateSelector(plates) {
|
||||
// Supprimer un éventuel sélecteur précédent
|
||||
document.getElementById('plateSelectorWrap')?.remove();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.id = 'plateSelectorWrap';
|
||||
wrap.className = 'mt-3 border rounded p-3 bg-light';
|
||||
wrap.innerHTML = '<div class="fw-semibold small mb-2"><i class="bi bi-layers me-1"></i>Choisir le plateau à imprimer :</div>';
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'd-flex flex-wrap gap-2';
|
||||
plates.forEach(p => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm btn-outline-warning';
|
||||
// Pastilles filaments
|
||||
const dots = p.filaments.map(f =>
|
||||
`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${f.color || '#ccc'};border:1px solid #aaa;margin-right:2px"></span>`
|
||||
).join('');
|
||||
const piecesLabel = p.pieces != null ? ` — ${p.pieces} pce` : '';
|
||||
const nameLabel = p.name ? ` «${p.name}»` : '';
|
||||
btn.innerHTML = `${dots}<strong>Plateau ${p.index}${nameLabel}</strong> — ${p.weight_g} g — ${Math.floor(p.print_time_s/3600)}h${String(Math.floor((p.print_time_s%3600)/60)).padStart(2,'0')}m${piecesLabel}`;
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('#plateSelectorWrap button').forEach(b => b.classList.replace('btn-warning','btn-outline-warning'));
|
||||
btn.classList.replace('btn-outline-warning','btn-warning');
|
||||
applyPlateData(
|
||||
p.weight_g,
|
||||
Math.floor(p.print_time_s/3600),
|
||||
Math.floor((p.print_time_s%3600)/60),
|
||||
p.pieces,
|
||||
p.name || `Plateau ${p.index}`,
|
||||
p.object_names
|
||||
);
|
||||
document.getElementById('parseStatus').innerHTML =
|
||||
`<span class="text-success"><i class="bi bi-check-circle me-1"></i>Plateau ${p.index} appliqué</span>`;
|
||||
});
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
wrap.appendChild(grid);
|
||||
// Insérer après le card d'import
|
||||
document.querySelector('.card.border-warning').insertAdjacentElement('afterend', wrap);
|
||||
}
|
||||
|
||||
// ── Coefficient design ────────────────────────────────────────────────────────
|
||||
@@ -544,8 +750,197 @@ function renderBreakdown(d, body) {
|
||||
` + capBlock;
|
||||
}
|
||||
|
||||
// Init
|
||||
updateDMLabel(1.80);
|
||||
updateMarginDisplay(27.5);
|
||||
// Init — utiliser les valeurs du champ (prefill ou défaut)
|
||||
const _initDM = parseFloat(document.getElementById('design_multiplier').value) || 1.80;
|
||||
const _initMargin = parseFloat(document.getElementById('gross_margin_pct').value) ?? 27.5;
|
||||
updateDMLabel(_initDM);
|
||||
updateMarginDisplay(_initMargin);
|
||||
if (document.getElementById('weight_g').value) recalc();
|
||||
|
||||
// ── Nextcloud File Picker ─────────────────────────────────────────────────────
|
||||
let ncPickerMode = 'source'; // 'source' = remplit seulement sourceFile, 'bambu' = remplit + parse 3MF
|
||||
(function () {
|
||||
const modal = document.getElementById('ncPickerModal');
|
||||
const body = document.getElementById('ncPickerBody');
|
||||
const bread = document.getElementById('ncBreadcrumb');
|
||||
const sfInput = document.getElementById('sourceFile');
|
||||
const searchInput = document.getElementById('ncSearchInput');
|
||||
const bambuHint = document.getElementById('ncBambuHint');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
let currentPath = '';
|
||||
let allItems = [];
|
||||
|
||||
async function browse(path) {
|
||||
searchInput.value = '';
|
||||
body.innerHTML = '<div class="text-center py-4"><div class="spinner-border spinner-border-sm"></div> Chargement…</div>';
|
||||
try {
|
||||
const r = await fetch('/api/nextcloud/browse?path=' + encodeURIComponent(path));
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
body.innerHTML = `<div class="alert alert-danger m-3">${d.error}</div>`;
|
||||
return;
|
||||
}
|
||||
currentPath = d.path;
|
||||
allItems = d.items;
|
||||
renderBreadcrumb(d.breadcrumb);
|
||||
renderItems(allItems);
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div class="alert alert-danger m-3">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', () => {
|
||||
const q = searchInput.value.trim().toLowerCase();
|
||||
if (!q) { renderItems(allItems); return; }
|
||||
const filtered = allItems.filter(it => it.name.toLowerCase().includes(q));
|
||||
renderItems(filtered);
|
||||
});
|
||||
|
||||
function renderBreadcrumb(crumbs) {
|
||||
bread.innerHTML = crumbs.map((c, i) =>
|
||||
i < crumbs.length - 1
|
||||
? `<li class="breadcrumb-item"><a href="#" class="nc-crumb" data-path="${c.path}">${c.name}</a></li>`
|
||||
: `<li class="breadcrumb-item active">${c.name}</li>`
|
||||
).join('');
|
||||
bread.querySelectorAll('.nc-crumb').forEach(a =>
|
||||
a.addEventListener('click', e => { e.preventDefault(); browse(a.dataset.path); }));
|
||||
}
|
||||
|
||||
const EXT_ICONS = {
|
||||
'3mf': 'bi-box-seam text-warning',
|
||||
'stl': 'bi-triangle text-info',
|
||||
'step': 'bi-bezier2 text-success',
|
||||
'stp': 'bi-bezier2 text-success',
|
||||
'obj': 'bi-grid-3x3 text-secondary',
|
||||
'gcode':'bi-code-square text-danger',
|
||||
'dir': 'bi-folder-fill text-warning',
|
||||
};
|
||||
|
||||
function fmtMtime(mtime) {
|
||||
if (!mtime) return '';
|
||||
try {
|
||||
const d = new Date(mtime);
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
if (diffMs < 86400000) return 'Aujourd\'hui';
|
||||
if (diffMs < 172800000) return 'Hier';
|
||||
if (diffMs < 604800000) return Math.floor(diffMs/86400000) + 'j';
|
||||
return d.toLocaleDateString('fr-FR', {day:'2-digit', month:'2-digit', year:'2-digit'});
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
if (!items.length) {
|
||||
body.innerHTML = '<div class="text-center text-muted py-4">Aucun fichier trouvé.</div>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = '';
|
||||
const list = document.createElement('div');
|
||||
list.className = 'list-group list-group-flush';
|
||||
items.forEach(item => {
|
||||
const icon = EXT_ICONS[item.ext] || 'bi-file text-muted';
|
||||
const meta = item.is_dir ? '' :
|
||||
`<small class="text-muted ms-auto text-nowrap" style="font-size:.75rem">
|
||||
${fmtMtime(item.mtime)}${item.size ? ' · ' + (item.size/1024/1024).toFixed(1) + ' Mo' : ''}
|
||||
</small>`;
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.className = 'list-group-item list-group-item-action d-flex align-items-center gap-2 py-2';
|
||||
el.innerHTML = `<i class="bi ${icon} fs-5" style="flex-shrink:0"></i>
|
||||
<span class="flex-fill text-start">${item.name}</span>
|
||||
${meta}
|
||||
${item.is_dir ? '<i class="bi bi-chevron-right text-muted"></i>' : '<i class="bi bi-check2-circle text-success"></i>'}`;
|
||||
if (item.is_dir) {
|
||||
el.addEventListener('click', () => browse(item.path));
|
||||
} else {
|
||||
el.addEventListener('click', () => selectFile(item));
|
||||
}
|
||||
list.appendChild(el);
|
||||
});
|
||||
body.appendChild(list);
|
||||
}
|
||||
|
||||
async function selectFile(item) {
|
||||
const nameLower = item.name.toLowerCase();
|
||||
const isGcode3mf = nameLower.endsWith('.gcode.3mf');
|
||||
const is3mf = nameLower.endsWith('.3mf');
|
||||
|
||||
// Fichier source : si c'est un gcode.3mf, pointer vers le .3mf projet correspondant
|
||||
if (isGcode3mf) {
|
||||
// Chercher le .3mf projet dans le même dossier
|
||||
const dir = item.path.substring(0, item.path.lastIndexOf('/') + 1);
|
||||
const base = item.name.replace(/\.gcode\.3mf$/i, '');
|
||||
sfInput.value = dir + base + '.3mf';
|
||||
} else {
|
||||
sfInput.value = item.path;
|
||||
}
|
||||
|
||||
// Auto-remplir le nom de commande si vide
|
||||
const nameField = document.getElementById('jobName');
|
||||
if (!nameField.value) {
|
||||
const cleanName = item.name.replace(/\.gcode\.3mf$/i, '').replace(/\.[^.]+$/, '');
|
||||
nameField.value = cleanName;
|
||||
}
|
||||
bootstrap.Modal.getInstance(modal).hide();
|
||||
|
||||
// Toujours sauvegarder le chemin Nextcloud pour permettre la duplication
|
||||
document.getElementById('sourceNcPath').value = item.path;
|
||||
|
||||
// Mode Bambu : télécharger et parser le 3MF (gcode.3mf ou projet)
|
||||
if (ncPickerMode === 'bambu' && (is3mf || isGcode3mf)) {
|
||||
const status = document.getElementById('parseStatus');
|
||||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Téléchargement…';
|
||||
try {
|
||||
const resp = await fetch('/api/nextcloud/file?path=' + encodeURIComponent(item.path));
|
||||
if (!resp.ok) throw new Error('Erreur téléchargement Nextcloud');
|
||||
const blob = await resp.blob();
|
||||
const file = new File([blob], item.name, { type: 'application/octet-stream' });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
document.getElementById('file3mf').files = dt.files;
|
||||
await parse3mf();
|
||||
} catch (e) {
|
||||
document.getElementById('parseStatus').innerHTML =
|
||||
`<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
ncPickerMode = 'source'; // reset
|
||||
}
|
||||
|
||||
// ── Auto-parse au chargement (duplication avec source_nc_path) ────────────
|
||||
(async function autoParseOnClone() {
|
||||
const ncPath = document.getElementById('sourceNcPath').value;
|
||||
if (!ncPath) return;
|
||||
const status = document.getElementById('parseStatus');
|
||||
if (!status) return;
|
||||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Rechargement des plateaux…';
|
||||
try {
|
||||
const r = await fetch('/api/nextcloud/parse?path=' + encodeURIComponent(ncPath));
|
||||
const d = await r.json();
|
||||
if (d.error) { status.innerHTML = `<span class="text-warning"><i class="bi bi-exclamation-triangle me-1"></i>${d.error}</span>`; return; }
|
||||
if (d.plates && d.plates.length > 1) {
|
||||
_3mfPlates = d.plates;
|
||||
renderPlateSelector(d.plates);
|
||||
status.innerHTML = `<span class="text-success">${d.plates.length} plateau(x) disponibles — choisissez ou modifiez</span>`;
|
||||
} else if (d.plates && d.plates.length === 1) {
|
||||
status.innerHTML = '<span class="text-success"><i class="bi bi-check-circle me-1"></i>Données extraites</span>';
|
||||
} else {
|
||||
status.innerHTML = '<span class="text-muted small">Fichier source lié — plateaux non disponibles</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
status.innerHTML = '<span class="text-muted small">Fichier source lié (réimportez si besoin)</span>';
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// Ouvrir = charger le dossier racine + afficher/masquer hint bambu
|
||||
modal.addEventListener('show.bs.modal', () => {
|
||||
bambuHint.classList.toggle('d-none', ncPickerMode !== 'bambu');
|
||||
searchInput.value = '';
|
||||
if (!currentPath) browse('');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+408
-26
@@ -49,6 +49,7 @@
|
||||
{% if unscheduled %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for j in unscheduled %}
|
||||
{% set missing = j.total_plates_needed - j.scheduled_plates %}
|
||||
<div class="list-group-item py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
@@ -58,6 +59,15 @@
|
||||
{% if j.order_qty > 1 %} · ×{{ j.order_qty }} pièces{% endif %}
|
||||
{% if j.client_name %} · {{ j.client_name }}{% endif %}
|
||||
</div>
|
||||
{% if j.total_plates_needed > 1 %}
|
||||
<div class="mt-1">
|
||||
<span class="badge bg-warning text-dark" style="font-size:.65rem">
|
||||
<i class="bi bi-layers me-1"></i>
|
||||
Plateau {{ j.scheduled_plates + 1 }}/{{ j.total_plates_needed }}
|
||||
{% if missing > 1 %} · {{ missing }} restants{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-success py-0"
|
||||
@@ -92,6 +102,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live HA Status -->
|
||||
<div class="col-md-5">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-activity me-2 text-success"></i>Imprimantes en direct</span>
|
||||
<span id="haLastUpdate" class="text-muted" style="font-size:.7rem"></span>
|
||||
</div>
|
||||
<div class="card-body p-2" id="haPrinterCards">
|
||||
<div class="text-center text-muted py-3 small">
|
||||
<i class="bi bi-hourglass-split me-1"></i>Chargement…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal planification -->
|
||||
@@ -123,13 +148,25 @@
|
||||
<label class="form-label fw-semibold">Date et heure de lancement</label>
|
||||
<input type="datetime-local" id="modalStart" class="form-control">
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="modalAlreadyDone" onchange="onAlreadyDoneChange()">
|
||||
<label class="form-check-label fw-semibold text-success" for="modalAlreadyDone">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>Déjà imprimé
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text ms-4">Enregistre le plateau comme terminé sans passer par le planning actif.</div>
|
||||
</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 id="modalError" class="alert alert-danger py-2 small d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i><span></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()">
|
||||
<button type="button" id="modalSaveBtn" class="btn fw-bold" style="background:#ff6b35;color:#fff" onclick="saveSlot()">
|
||||
<i class="bi bi-save me-1"></i>Planifier
|
||||
</button>
|
||||
</div>
|
||||
@@ -148,9 +185,20 @@
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="slotModalId">
|
||||
<p id="slotModalInfo" class="text-muted small"></p>
|
||||
|
||||
<!-- Quick action buttons -->
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button class="btn btn-sm btn-success flex-fill" onclick="quickAction('done')">
|
||||
<i class="bi bi-check-circle me-1"></i>Terminé
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger flex-fill" onclick="quickAction('fail')">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>Échec
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Statut</label>
|
||||
<select id="slotModalStatus" class="form-select">
|
||||
<select id="slotModalStatus" class="form-select" onchange="onStatusChange()">
|
||||
<option value="planned">Planifié</option>
|
||||
<option value="printing">En cours</option>
|
||||
<option value="done">Terminé ✓</option>
|
||||
@@ -159,9 +207,19 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3" id="slotModalNoteGroup">
|
||||
<label class="form-label">Note (optionnel)</label>
|
||||
<label class="form-label">Note d'échec (optionnel)</label>
|
||||
<input type="text" id="slotModalNote" class="form-control" placeholder="Ex: couche 45 — sous-extrusion">
|
||||
</div>
|
||||
<div class="mb-3" id="slotModalWastedGroup" style="display:none">
|
||||
<label class="form-label">Filament gaspillé (g) <small class="text-muted fw-normal">optionnel</small></label>
|
||||
<input type="number" id="slotModalWasted" class="form-control" step="0.1" min="0"
|
||||
placeholder="Poids plateau si vide">
|
||||
</div>
|
||||
<div class="mb-3" id="slotModalActualGroup" style="display:none">
|
||||
<label class="form-label">Poids réel consommé (g) <small class="text-muted fw-normal">optionnel</small></label>
|
||||
<input type="number" id="slotModalActual" class="form-control" step="0.1" min="0"
|
||||
placeholder="Pour déduire du stock">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between">
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteSlot()">
|
||||
@@ -181,6 +239,8 @@
|
||||
<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 HA_POLL_MS = {{ (settings.get('ha_poll_interval', '60') | int) * 1000 }};
|
||||
|
||||
const STATUS_COLORS = {
|
||||
planned: '#3b82f6',
|
||||
printing: '#f59e0b',
|
||||
@@ -194,6 +254,58 @@ const STATUS_LABELS = {
|
||||
|
||||
let timeline, items, groups;
|
||||
|
||||
/**
|
||||
* Calcule les périodes où le nombre de slots actifs atteint max_printers
|
||||
* et retourne des items background vis.js pour les afficher en rouge.
|
||||
*/
|
||||
function buildCapacityBands(slots, maxPrinters) {
|
||||
const active = slots.filter(s => !['cancelled','failed','done'].includes(s.status));
|
||||
if (active.length < maxPrinters) return [];
|
||||
|
||||
// Collecter tous les événements (début/fin)
|
||||
const events = [];
|
||||
active.forEach(s => {
|
||||
events.push({ t: new Date(s.planned_start), d: +1 });
|
||||
events.push({ t: new Date(s.planned_end), d: -1 });
|
||||
});
|
||||
events.sort((a, b) => a.t - b.t);
|
||||
|
||||
const bands = [];
|
||||
let count = 0;
|
||||
let bandStart = null;
|
||||
let bgId = 0;
|
||||
|
||||
for (const ev of events) {
|
||||
count += ev.d;
|
||||
if (count >= maxPrinters && bandStart === null) {
|
||||
bandStart = ev.t;
|
||||
} else if (count < maxPrinters && bandStart !== null) {
|
||||
bands.push({
|
||||
id: 'cap_' + (bgId++),
|
||||
start: bandStart,
|
||||
end: ev.t,
|
||||
type: 'background',
|
||||
className: '',
|
||||
style: 'background: rgba(239,68,68,.18); border-left: 2px solid rgba(239,68,68,.5);',
|
||||
title: `⚠️ Capacité max atteinte (${maxPrinters} impressions simultanées)`,
|
||||
});
|
||||
bandStart = null;
|
||||
}
|
||||
}
|
||||
if (bandStart !== null) {
|
||||
bands.push({
|
||||
id: 'cap_' + (bgId++),
|
||||
start: bandStart,
|
||||
end: new Date(bandStart.getTime() + 86400000),
|
||||
type: 'background',
|
||||
className: '',
|
||||
style: 'background: rgba(239,68,68,.18); border-left: 2px solid rgba(239,68,68,.5);',
|
||||
title: `⚠️ Capacité max atteinte (${maxPrinters} impressions simultanées)`,
|
||||
});
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
async function initTimeline() {
|
||||
const [slotsRes, availRes] = await Promise.all([
|
||||
fetch('/api/slots').then(r => r.json()),
|
||||
@@ -218,24 +330,40 @@ async function initTimeline() {
|
||||
}));
|
||||
|
||||
// Print slots
|
||||
const slotItems = slotsRes.map(s => ({
|
||||
const slotItems = slotsRes.map(s => {
|
||||
const h = Math.floor(s.print_time_s / 3600);
|
||||
const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0');
|
||||
const dur = `${h}h${mn}`;
|
||||
const dot = s.material_color
|
||||
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.material_color};border:1px solid rgba(255,255,255,.5);margin-right:3px;flex-shrink:0;vertical-align:middle"></span>`
|
||||
: '';
|
||||
const platePart = s.plate_name ? ` · ${s.plate_name}` : '';
|
||||
const filLine = s.material_name ? `<br>🧵 ${s.material_name} ${s.material_type || ''}` : '';
|
||||
const clientLine = s.client_name ? `<br>👤 ${s.client_name}` : '';
|
||||
const plateLine = s.plate_name ? `<br>📋 ${s.plate_name}` : '';
|
||||
return {
|
||||
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}`,
|
||||
content: `<span style="font-size:.7rem;display:flex;align-items:center;gap:2px;white-space:nowrap">${dot}<strong>${s.job_name}</strong><span style="opacity:.75;font-weight:normal"> (${dur})</span></span>`,
|
||||
title: `<strong>${s.job_name}</strong>${plateLine}${clientLine}<br>⏱ ${dur}${filLine}<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]);
|
||||
// Bandes rouges "capacité saturée" (toutes imprimantes, groupe null = full-width background)
|
||||
const MAX_PRINTERS = {{ settings.get('max_simultaneous_printers', 5) }};
|
||||
const capacityBgItems = buildCapacityBands(slotsRes, MAX_PRINTERS);
|
||||
|
||||
items = new vis.DataSet([...bgItems, ...slotItems, ...capacityBgItems]);
|
||||
|
||||
const now = new Date();
|
||||
timeline = new vis.Timeline(
|
||||
document.getElementById('timeline'),
|
||||
items, groups,
|
||||
{
|
||||
start: now,
|
||||
start: new Date(now.getTime() - 12 * 3600000),
|
||||
end: new Date(now.getTime() + 14 * 86400000),
|
||||
groupOrder: 'id',
|
||||
selectable: true,
|
||||
@@ -269,20 +397,134 @@ function renderSlotsList(slots) {
|
||||
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>
|
||||
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 30);
|
||||
|
||||
// Pre-compute per-job stats using job_total_ignored from API (covers all slots, even old ones)
|
||||
const jobMeta = {};
|
||||
for (const s of upcoming) {
|
||||
if (!jobMeta[s.job_id]) {
|
||||
jobMeta[s.job_id] = {
|
||||
name: s.job_name,
|
||||
totalPlates: s.total_plates,
|
||||
totalIgnored: s.job_total_ignored, // sum across ALL slots of job
|
||||
pendingSlots: []
|
||||
};
|
||||
}
|
||||
if (['planned','running'].includes(s.status)) jobMeta[s.job_id].pendingSlots.push(s);
|
||||
}
|
||||
|
||||
let rows = '';
|
||||
const seenJobs = new Set();
|
||||
|
||||
for (const s of upcoming) {
|
||||
const job = jobMeta[s.job_id];
|
||||
const isMulti = job.totalPlates > 1;
|
||||
|
||||
// ── Job group header (once per multi-plate job) ──────────────────────
|
||||
if (isMulti && !seenJobs.has(s.job_id)) {
|
||||
seenJobs.add(s.job_id);
|
||||
const deficit = job.totalIgnored;
|
||||
const alreadyCompensated = job.pendingSlots.reduce((sum, sl) =>
|
||||
sum + (sl.pieces_override !== null ? Math.max(0, sl.pieces_override - sl.pieces_per_plate) : 0), 0);
|
||||
const remaining = deficit - alreadyCompensated;
|
||||
const defBadge = remaining > 0
|
||||
? `<span class="badge ms-2" style="background:#fbbf24;color:#78350f;font-size:.65rem">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>${remaining} pièce(s) à compenser
|
||||
</span>`
|
||||
: (deficit > 0
|
||||
? `<span class="badge bg-success ms-2" style="font-size:.65rem"><i class="bi bi-check-circle-fill me-1"></i>Compensé</span>`
|
||||
: '');
|
||||
rows += `<tr style="background:#eef2ff;border-top:2px solid #c7d2fe">
|
||||
<td colspan="5" style="padding:.3rem .75rem">
|
||||
<span style="font-size:.78rem;font-weight:600;color:#3730a3">
|
||||
<i class="bi bi-layers-half me-1"></i>${s.job_name} — ${job.totalPlates} plateaux
|
||||
</span>${defBadge}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ── Individual slot row ──────────────────────────────────────────────
|
||||
const h = Math.floor(s.print_time_s / 3600);
|
||||
const mn = String(Math.floor((s.print_time_s % 3600) / 60)).padStart(2,'0');
|
||||
const dur = `${h}h${mn}`;
|
||||
const dot = s.material_color
|
||||
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${s.material_color};border:1px solid #ccc;margin-right:3px;vertical-align:middle"></span>`
|
||||
: '';
|
||||
|
||||
// Plate label: for multi-plate show "Plateau X/Y", else fallback to plate_name
|
||||
const platePart = isMulti
|
||||
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>Plateau ${s.plate_num}/${s.total_plates}</span>`
|
||||
: (s.plate_name
|
||||
? `<br><span style="font-size:.7rem;color:#6b7280"><i class="bi bi-layers me-1"></i>${s.plate_name}</span>`
|
||||
: '');
|
||||
|
||||
const filPart = s.material_name
|
||||
? `<br><span style="font-size:.7rem;color:#6b7280">${dot}${s.material_name} ${s.material_type||''}</span>`
|
||||
: '';
|
||||
const clientPart = s.client_name
|
||||
? `<br><span class="text-muted fw-normal" style="font-size:.75rem">${s.client_name}</span>`
|
||||
: '';
|
||||
|
||||
// Pieces delivered on this slot (if already printed with ignores)
|
||||
const ignoredPart = s.pieces_ignored > 0
|
||||
? `<br><span style="font-size:.7rem;background:#fef3c7;color:#92400e;padding:1px 5px;border-radius:3px">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>${s.effective_pieces - s.pieces_ignored}/${s.effective_pieces} pièces livrées
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
// Override badge (slot was manually adjusted)
|
||||
const overridePart = s.pieces_override !== null
|
||||
? `<br><span style="font-size:.7rem;background:#dcfce7;color:#166534;padding:1px 5px;border-radius:3px">
|
||||
<i class="bi bi-check-circle-fill me-1"></i>${s.pieces_override} pièces (ajusté)
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
// Compensation needed on this pending slot
|
||||
const isPending = ['planned','running'].includes(s.status);
|
||||
const deficit = job.totalIgnored;
|
||||
const alreadyComp = job.pendingSlots.reduce((sum, sl) =>
|
||||
sum + (sl.pieces_override !== null ? Math.max(0, sl.pieces_override - sl.pieces_per_plate) : 0), 0);
|
||||
const stillNeeded = deficit - alreadyComp;
|
||||
const compensatePart = (isPending && stillNeeded > 0 && s.pieces_ignored === 0 && s.pieces_override === null)
|
||||
? `<br><span style="font-size:.7rem;background:#fff7ed;color:#9a3412;padding:1px 6px;border-radius:3px;cursor:pointer"
|
||||
onclick="event.stopPropagation();applyCompensation(${s.id}, ${s.effective_pieces}, ${stillNeeded})">
|
||||
<i class="bi bi-arrow-up-circle-fill me-1"></i>Compenser : imprimer ${s.effective_pieces + stillNeeded} ✏️
|
||||
</span>`
|
||||
: '';
|
||||
|
||||
// For multi-plate jobs, don't repeat the job name in each row (it's in the header)
|
||||
const jobLabel = isMulti ? '' : `${s.job_name}`;
|
||||
|
||||
rows += `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'"')})">
|
||||
<td class="small" style="white-space:nowrap">${formatDt(s.planned_start)}</td>
|
||||
<td class="small fw-semibold">${jobLabel}${platePart}${filPart}${clientPart}${ignoredPart}${overridePart}${compensatePart}</td>
|
||||
<td class="small text-muted" style="white-space:nowrap">${dur}</td>
|
||||
<td class="small" style="white-space:nowrap">${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>`;
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
el.innerHTML = `<div class="table-responsive"><table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light"><tr>
|
||||
<th class="small">Début</th>
|
||||
<th class="small">Job / Plateau</th>
|
||||
<th class="small">Durée</th>
|
||||
<th class="small">Machine</th>
|
||||
<th class="small">Statut</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
async function applyCompensation(slotId, currentPieces, deficit) {
|
||||
const newVal = currentPieces + deficit;
|
||||
if (!confirm(`Ajuster ce plateau à ${newVal} pièces (au lieu de ${currentPieces}) pour compenser les ${deficit} pièce(s) ignorée(s) ?`)) return;
|
||||
const res = await fetch(`/api/slots/${slotId}/pieces_override`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({pieces: newVal})
|
||||
}).then(r => r.json());
|
||||
if (res.ok) loadSlots();
|
||||
}
|
||||
|
||||
function formatDt(iso) {
|
||||
@@ -292,11 +534,33 @@ function formatDt(iso) {
|
||||
d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
|
||||
function onAlreadyDoneChange() {
|
||||
const done = document.getElementById('modalAlreadyDone').checked;
|
||||
const btn = document.getElementById('modalSaveBtn');
|
||||
if (done) {
|
||||
btn.innerHTML = '<i class="bi bi-check-circle me-1"></i>Enregistrer comme terminé';
|
||||
btn.style.background = '#22c55e';
|
||||
// Reset to "now" so user picks when it was done
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
document.getElementById('modalStart').value = now.toISOString().slice(0,16);
|
||||
document.getElementById('modalSuggestion').classList.add('d-none');
|
||||
document.getElementById('modalError').classList.add('d-none');
|
||||
} else {
|
||||
btn.innerHTML = '<i class="bi bi-save me-1"></i>Planifier';
|
||||
btn.style.background = '#ff6b35';
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
document.getElementById('modalError').classList.add('d-none');
|
||||
// Reset already-done checkbox
|
||||
document.getElementById('modalAlreadyDone').checked = false;
|
||||
onAlreadyDoneChange();
|
||||
// Default: now rounded to next hour
|
||||
const now = new Date();
|
||||
now.setMinutes(0, 0, 0);
|
||||
@@ -329,20 +593,34 @@ 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', {
|
||||
const alreadyDone = document.getElementById('modalAlreadyDone').checked;
|
||||
// Clear previous error
|
||||
document.getElementById('modalError').classList.add('d-none');
|
||||
const r = 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());
|
||||
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start,
|
||||
already_done: alreadyDone})
|
||||
});
|
||||
const res = await r.json();
|
||||
if (res.ok) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Erreur : ' + (res.error || 'inconnu'));
|
||||
const errEl = document.getElementById('modalError');
|
||||
errEl.querySelector('span').textContent = res.error || 'Erreur inconnue';
|
||||
errEl.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
let currentSlot = null;
|
||||
|
||||
function onStatusChange() {
|
||||
const status = document.getElementById('slotModalStatus').value;
|
||||
document.getElementById('slotModalWastedGroup').style.display = status === 'failed' ? '' : 'none';
|
||||
document.getElementById('slotModalActualGroup').style.display = status === 'done' ? '' : 'none';
|
||||
}
|
||||
|
||||
function openSlotModal(slot) {
|
||||
currentSlot = slot;
|
||||
document.getElementById('slotModalId').value = slot.id;
|
||||
@@ -350,17 +628,55 @@ function openSlotModal(slot) {
|
||||
`${slot.job_name} · ${slot.printer_name} · ${formatDt(slot.planned_start)}`;
|
||||
document.getElementById('slotModalStatus').value = slot.status || 'planned';
|
||||
document.getElementById('slotModalNote').value = slot.failure_note || '';
|
||||
document.getElementById('slotModalWasted').value = '';
|
||||
document.getElementById('slotModalActual').value = '';
|
||||
onStatusChange();
|
||||
new bootstrap.Modal(document.getElementById('slotModal')).show();
|
||||
}
|
||||
|
||||
async function quickAction(type) {
|
||||
// Submit immediately via the dedicated fail/done endpoints
|
||||
const id = document.getElementById('slotModalId').value;
|
||||
const note = document.getElementById('slotModalNote').value;
|
||||
const wasted_g = document.getElementById('slotModalWasted').value || '';
|
||||
const actual_g = document.getElementById('slotModalActual').value || '';
|
||||
|
||||
if (type === 'done') {
|
||||
const fd = new FormData();
|
||||
fd.append('actual_g', actual_g);
|
||||
await fetch(`/api/slots/${id}/done`, {method:'POST', body:fd});
|
||||
} else {
|
||||
const fd = new FormData();
|
||||
fd.append('failure_note', note);
|
||||
fd.append('wasted_g', wasted_g);
|
||||
await fetch(`/api/slots/${id}/fail`, {method:'POST', body:fd});
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function updateSlotStatus() {
|
||||
const id = document.getElementById('slotModalId').value;
|
||||
const status = document.getElementById('slotModalStatus').value;
|
||||
const note = document.getElementById('slotModalNote').value;
|
||||
const wasted = document.getElementById('slotModalWasted').value;
|
||||
const actual = document.getElementById('slotModalActual').value;
|
||||
|
||||
if (status === 'done') {
|
||||
const fd = new FormData();
|
||||
fd.append('actual_g', actual);
|
||||
await fetch(`/api/slots/${id}/done`, {method:'POST', body:fd});
|
||||
} else if (status === 'failed') {
|
||||
const fd = new FormData();
|
||||
fd.append('failure_note', note);
|
||||
fd.append('wasted_g', wasted);
|
||||
await fetch(`/api/slots/${id}/fail`, {method:'POST', body:fd});
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
@@ -374,5 +690,71 @@ async function deleteSlot() {
|
||||
}
|
||||
|
||||
initTimeline();
|
||||
|
||||
// ── Home Assistant live status ──────────────────────────────────────────────
|
||||
async function refreshHaStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/ha/status');
|
||||
const d = await r.json();
|
||||
const el = document.getElementById('haPrinterCards');
|
||||
const lu = document.getElementById('haLastUpdate');
|
||||
lu.textContent = 'Mis à jour ' + new Date().toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit'});
|
||||
if (d.error && !d.printers) {
|
||||
el.innerHTML = `<div class="text-muted small p-3"><i class="bi bi-info-circle me-1"></i>${d.error}</div>`;
|
||||
return;
|
||||
}
|
||||
const printers = d.printers || {};
|
||||
const keys = Object.keys(printers);
|
||||
if (!keys.length) {
|
||||
el.innerHTML = '<div class="text-muted small p-3"><i class="bi bi-info-circle me-1"></i>Aucune imprimante avec préfixe HA. Configurez dans Paramètres + Imprimantes.</div>';
|
||||
return;
|
||||
}
|
||||
const STATUS_LABEL = {running:'En cours',finish:'Terminé',idle:'Idle',pause:'En pause',failed:'Erreur',offline:'Hors ligne',init:'Démarrage',unknown:'—'};
|
||||
const STATUS_CLS = {running:'success',finish:'secondary',idle:'light',pause:'warning',failed:'danger',offline:'dark',init:'info',unknown:'light'};
|
||||
el.innerHTML = keys.map(pid => {
|
||||
const p = printers[pid];
|
||||
const sc = STATUS_CLS[p.status] || 'light';
|
||||
const sl = STATUS_LABEL[p.status] || p.status;
|
||||
const prog = p.progress || 0;
|
||||
const barColor = p.error ? '#ef4444' : p.status === 'running' ? '#22c55e' : '#94a3b8';
|
||||
const layerInfo = (p.layers_total > 0)
|
||||
? `<span class="text-muted" style="font-size:.7rem">Couche ${p.layer}/${p.layers_total}</span>`
|
||||
: '';
|
||||
const finInfo = (p.heure_fin && p.heure_fin !== 'unknown' && p.status === 'running')
|
||||
? `<span class="text-muted" style="font-size:.7rem">Fin ≈ ${new Date(p.heure_fin).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})}</span>`
|
||||
: '';
|
||||
const taskInfo = (p.task_name && p.task_name !== 'unknown')
|
||||
? `<div class="text-truncate" style="font-size:.72rem;color:#6b7280;max-width:220px" title="${p.task_name}">${p.task_name}</div>`
|
||||
: '';
|
||||
const ignoredWarn = (p.pieces_ignored > 0)
|
||||
? `<div class="mt-1"><span class="badge" style="background:#fef3c7;color:#92400e;font-size:.7rem">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>${p.pieces_total - p.pieces_ignored}/${p.pieces_total} pièces — ${p.pieces_ignored} ignorée(s)
|
||||
</span></div>`
|
||||
: '';
|
||||
return `<div class="p-2 mb-1 rounded" style="background:#f8f9fa;border:1px solid ${p.pieces_ignored>0?'#fbbf24':'#e5e7eb'}">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-semibold small">${p.printer_name}</span>
|
||||
<span class="badge bg-${sc} text-${sc==='light'?'dark':'white'}">${sl}</span>
|
||||
</div>
|
||||
${taskInfo}
|
||||
${ignoredWarn}
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<div class="flex-fill me-2">
|
||||
<div style="height:6px;background:#e5e7eb;border-radius:3px;overflow:hidden">
|
||||
<div style="width:${prog}%;height:100%;background:${barColor};transition:width .5s"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fw-bold small" style="min-width:36px;text-align:right">${prog}%</span>
|
||||
</div>
|
||||
<div class="d-flex gap-3 mt-1">${layerInfo}${finInfo}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
console.warn('HA status error:', e);
|
||||
}
|
||||
}
|
||||
refreshHaStatus();
|
||||
setInterval(refreshHaStatus, HA_POLL_MS);
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Commande introuvable</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>body { background:#f4f6f9; display:flex; align-items:center; justify-content:center; min-height:100vh; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="text-center">
|
||||
<i class="bi bi-question-circle fs-1 text-muted d-block mb-3"></i>
|
||||
<h4>Commande introuvable</h4>
|
||||
<p class="text-muted">Ce lien de suivi est invalide ou a été révoqué.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+66
-1
@@ -15,7 +15,7 @@
|
||||
<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>
|
||||
<tr><th>Nom</th><th>Statut</th><th>Préfixe HA</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
@@ -28,9 +28,20 @@
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if p.ha_entity_prefix %}
|
||||
<code class="small text-primary">{{ p.ha_entity_prefix }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted small">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ p.notes or '—' }}</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-secondary py-0"
|
||||
onclick="openEditModal({{ p.id }}, '{{ p.name|replace("'","\\'") }}', '{{ p.ha_entity_prefix or '' }}', '{{ p.notes|replace("'","\\'")|default('') }}')">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<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 %}
|
||||
@@ -59,6 +70,12 @@
|
||||
<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">Préfixe entité Home Assistant <span class="text-muted fw-normal small">(optionnel)</span></label>
|
||||
<input type="text" name="ha_entity_prefix" class="form-control font-monospace"
|
||||
placeholder="ex : patabambulab01">
|
||||
<div class="form-text">Préfixe HA pour la synchro automatique du planning. Configurer l'URL HA dans <a href="{{ url_for('settings') }}">Paramètres</a>.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<input type="text" name="notes" class="form-control" placeholder="couleur, emplacement…">
|
||||
@@ -80,4 +97,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal édition imprimante -->
|
||||
<div class="modal fade" id="editPrinterModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-pencil me-2"></i>Modifier l'imprimante</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="editPrinterForm" method="POST">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nom</label>
|
||||
<input type="text" name="name" id="editName" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Préfixe Home Assistant <span class="text-muted fw-normal small">(optionnel)</span></label>
|
||||
<input type="text" name="ha_entity_prefix" id="editHaPrefix" class="form-control font-monospace"
|
||||
placeholder="ex : patabambulab01">
|
||||
<div class="form-text">Laisser vide pour désactiver la synchro HA.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<input type="text" name="notes" id="editNotes" class="form-control" placeholder="couleur, emplacement…">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
||||
<button type="submit" class="btn fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-1"></i>Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openEditModal(id, name, haPrefix, notes) {
|
||||
document.getElementById('editPrinterForm').action = '/printers/' + id + '/edit';
|
||||
document.getElementById('editName').value = name;
|
||||
document.getElementById('editHaPrefix').value = haPrefix;
|
||||
document.getElementById('editNotes').value = notes;
|
||||
new bootstrap.Modal(document.getElementById('editPrinterModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ project.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<a href="{{ url_for('projects') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left"></i> Projets
|
||||
</a>
|
||||
<h1 class="mt-1">{{ project.name }}</h1>
|
||||
<span class="text-muted small">{{ project.client_name or 'Sans client' }}</span>
|
||||
{% if project.deadline %}
|
||||
<span class="badge bg-secondary ms-2">Livraison : {{ project.deadline }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('new_job') }}?project_id={{ project.id }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-plus-circle me-1"></i>Ajouter un run
|
||||
</a>
|
||||
<a href="{{ url_for('edit_project', id=project.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i>Modifier
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Totaux -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="label">Runs</div>
|
||||
<div class="value">{{ jobs|length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="label">Pièces totales</div>
|
||||
<div class="value">{{ total_pieces }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="label">CA total commande</div>
|
||||
<div class="value" style="color:#ff6b35">{{ "%.2f"|format(total_revenue) }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="label">Prix moyen / pièce</div>
|
||||
<div class="value">
|
||||
{% if total_pieces > 0 %}{{ "%.2f"|format(total_revenue / total_pieces) }} €{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if project.notes %}
|
||||
<div class="alert alert-light border mb-4"><i class="bi bi-sticky me-2"></i>{{ project.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Liste des runs -->
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-list-ul me-2"></i>Runs d'impression</span>
|
||||
</div>
|
||||
{% if jobs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Nom du run</th>
|
||||
<th>Matière</th>
|
||||
<th class="text-center">Pièces/plateau</th>
|
||||
<th class="text-center">Qté commandée</th>
|
||||
<th class="text-center">Plateaux</th>
|
||||
<th class="text-end">Prix/pièce</th>
|
||||
<th class="text-end">Total run</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set ns = namespace(total_plateaux=0) %}
|
||||
{% for j in jobs %}
|
||||
{% set qty = j.order_qty or 1 %}
|
||||
{% set ppp = j.pieces_per_plate or 1 %}
|
||||
{% set nb_p = ((qty + ppp - 1) // ppp) %}
|
||||
{% set ns.total_plateaux = ns.total_plateaux + nb_p %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('job_detail', id=j.id) }}" class="fw-semibold text-decoration-none">
|
||||
{{ j.name }}
|
||||
</a>
|
||||
<div class="small text-muted">{{ j.created_at[:10] }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if j.material_name %}
|
||||
<span class="badge bg-secondary">{{ j.material_type }}</span> {{ j.material_name }}
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-center">{{ ppp }}</td>
|
||||
<td class="text-center fw-semibold">{{ qty }}</td>
|
||||
<td class="text-center">{{ nb_p }}</td>
|
||||
<td class="text-end">{{ "%.2f"|format(j.final_price) }} €</td>
|
||||
<td class="text-end fw-bold" style="color:#ff6b35">{{ "%.2f"|format(j.final_price * qty) }} €</td>
|
||||
<td>
|
||||
<a href="{{ url_for('job_detail', id=j.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="table-light fw-bold">
|
||||
<tr>
|
||||
<td colspan="3">Total</td>
|
||||
<td class="text-center">{{ total_pieces }}</td>
|
||||
<td class="text-center">{{ ns.total_plateaux }}</td>
|
||||
<td class="text-end">
|
||||
{% if total_pieces > 0 %}{{ "%.2f"|format(total_revenue / total_pieces) }} € moy.{% endif %}
|
||||
</td>
|
||||
<td class="text-end" style="color:#ff6b35">{{ "%.2f"|format(total_revenue) }} €</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-center text-muted py-4">
|
||||
<i class="bi bi-printer fs-2 d-block mb-2"></i>
|
||||
Aucun run encore.
|
||||
<a href="{{ url_for('new_job') }}">Créer un calcul</a> et l'associer à ce projet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{% if project %}Modifier{% else %}Nouveau{% endif %} projet{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<a href="{{ url_for('projects') }}" class="text-muted text-decoration-none small">
|
||||
<i class="bi bi-arrow-left"></i> Projets
|
||||
</a>
|
||||
<h1 class="mt-1">{% if project %}Modifier {{ project.name }}{% else %}Nouveau projet{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Nom du projet *</label>
|
||||
<input type="text" name="name" class="form-control" required
|
||||
value="{{ project.name if project else '' }}"
|
||||
placeholder="ex: Commande BlokGrip Q3 2026">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Client</label>
|
||||
<select name="client_id" class="form-select">
|
||||
<option value="">— Sans client —</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if project and project.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Date limite de livraison</label>
|
||||
<input type="date" name="deadline" class="form-control"
|
||||
value="{{ project.deadline if project and project.deadline else '' }}">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="3"
|
||||
placeholder="Description, contraintes, références...">{{ project.notes if project else '' }}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-1"></i>Enregistrer
|
||||
</button>
|
||||
<a href="{{ url_for('projects') }}" class="btn btn-outline-secondary">Annuler</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Projets{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1><i class="bi bi-folder2-open me-2 text-secondary"></i>Projets</h1>
|
||||
<p class="text-muted small mt-1">Regroupez plusieurs impressions (runs) sous un même projet client.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('new_project') }}" class="btn" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-plus-lg me-1"></i>Nouveau projet
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if projects %}
|
||||
<div class="row g-3">
|
||||
{% for p in projects %}
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<h5 class="mb-0">
|
||||
<a href="{{ url_for('project_detail', id=p.id) }}" class="text-decoration-none text-dark">
|
||||
{{ p.name }}
|
||||
</a>
|
||||
</h5>
|
||||
<small class="text-muted">{{ p.client_name or 'Sans client' }}</small>
|
||||
</div>
|
||||
{% if p.deadline %}
|
||||
<span class="badge bg-secondary">{{ p.deadline }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-4 mt-3">
|
||||
<div>
|
||||
<div class="small text-muted">Runs</div>
|
||||
<div class="fw-bold">{{ p.job_count }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-muted">Pièces totales</div>
|
||||
<div class="fw-bold">{{ p.total_pieces }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-muted">CA total</div>
|
||||
<div class="fw-bold" style="color:#ff6b35">{{ "%.2f"|format(p.total_revenue) }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent d-flex gap-2 py-2">
|
||||
<a href="{{ url_for('project_detail', id=p.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i> Voir
|
||||
</a>
|
||||
<a href="{{ url_for('edit_project', id=p.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Modifier
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('delete_project', id=p.id) }}"
|
||||
onsubmit="return confirm('Supprimer ce projet ? Les jobs seront conservés mais détachés.')">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-folder2 fs-1 d-block mb-3"></i>
|
||||
Aucun projet. <a href="{{ url_for('new_project') }}">Créer le premier</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
+173
-3
@@ -194,12 +194,182 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-cloud me-2"></i>Nextcloud — Intégration fichiers 3D</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">URL Nextcloud</label>
|
||||
<input type="url" name="nc_url" class="form-control"
|
||||
placeholder="https://nextcloud.tondomain.com"
|
||||
value="{{ settings.get('nc_url','') }}">
|
||||
<div class="form-text">Sans slash final.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Identifiant</label>
|
||||
<input type="text" name="nc_username" class="form-control"
|
||||
placeholder="ton_login"
|
||||
value="{{ settings.get('nc_username','') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label fw-semibold">Mot de passe d'application</label>
|
||||
<input type="password" name="nc_app_password" class="form-control"
|
||||
placeholder="••••••••••••"
|
||||
value="{{ settings.get('nc_app_password','') }}">
|
||||
<div class="form-text">Générer dans Nextcloud → Paramètres → Sécurité.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Dossier racine 3D</label>
|
||||
<input type="text" name="nc_root_path" class="form-control"
|
||||
placeholder="/Impressions3D"
|
||||
value="{{ settings.get('nc_root_path','/3D') }}">
|
||||
<div class="form-text">Chemin de base pour l'explorateur de fichiers.</div>
|
||||
</div>
|
||||
<div class="col-md-8 d-flex align-items-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="testNcBtn">
|
||||
<i class="bi bi-plug me-1"></i>Tester la connexion
|
||||
</button>
|
||||
<span id="testNcResult" class="ms-3 small"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn px-4 py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-2"></i>Enregistrer les parametres
|
||||
<!-- Home Assistant -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-house-heart me-2"></i>Home Assistant — Sync imprimantes</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">URL Home Assistant</label>
|
||||
<input type="url" name="ha_url" class="form-control"
|
||||
placeholder="https://homeassistant.tondomain.com"
|
||||
value="{{ settings.get('ha_url','') }}">
|
||||
<div class="form-text">Sans slash final. Ex : <code>https://homeassistant.lespatas.ovh</code></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Token longue durée</label>
|
||||
<input type="password" name="ha_token" class="form-control font-monospace"
|
||||
placeholder="••••••••••••••••••••"
|
||||
value="{{ settings.get('ha_token','') }}">
|
||||
<div class="form-text">Profil HA → Sécurité → Jetons d'accès longue durée.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Intervalle de synchro</label>
|
||||
<div class="input-group">
|
||||
<input type="number" name="ha_poll_interval" class="form-control"
|
||||
min="10" max="3600" step="5"
|
||||
value="{{ settings.get('ha_poll_interval','60') }}">
|
||||
<span class="input-group-text">secondes</span>
|
||||
</div>
|
||||
<div class="form-text">Min 10 s. Recommandé : 60 s pour ne pas surcharger HA.</div>
|
||||
</div>
|
||||
<div class="col-md-8 d-flex align-items-end">
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="testHaBtn">
|
||||
<i class="bi bi-plug me-1"></i>Tester la connexion HA
|
||||
</button>
|
||||
<span id="testHaResult" class="ms-3 small"></span>
|
||||
<div class="form-text mt-1">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Configurez ensuite le <strong>Préfixe entité HA</strong> pour chaque imprimante dans la page <a href="{{ url_for('printers_page') }}">Imprimantes</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iCal -->
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header py-3"><i class="bi bi-calendar-check me-2"></i>Calendrier iCal — Abonnement Android / iOS</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label fw-semibold">Token secret (optionnel)</label>
|
||||
<input type="text" name="ical_token" class="form-control font-monospace"
|
||||
placeholder="Laisser vide = pas de protection"
|
||||
value="{{ settings.get('ical_token','') }}">
|
||||
<div class="form-text">Si renseigné, l'URL contiendra <code>?token=xxx</code>.</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label fw-semibold">URL d'abonnement</label>
|
||||
<div class="input-group">
|
||||
<input type="text" id="icalUrl" class="form-control font-monospace small" readonly
|
||||
value="{{ request.host_url }}calendar.ics{% if settings.get('ical_token','') %}?token={{ settings.get('ical_token','') }}{% endif %}">
|
||||
<button class="btn btn-outline-secondary" type="button" id="copyIcal" title="Copier">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Dans Google Calendar → Autres agendas → Depuis une URL.
|
||||
Samsung / iOS : Paramètres → Comptes → Ajouter → Calendrier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton sauvegarde -->
|
||||
<div class="col-12 pb-2">
|
||||
<button type="submit" class="btn px-4 py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
||||
<i class="bi bi-save me-2"></i>Enregistrer les paramètres
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('copyIcal').addEventListener('click', function () {
|
||||
const url = document.getElementById('icalUrl').value;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.innerHTML = '<i class="bi bi-check text-success"></i>';
|
||||
setTimeout(() => { this.innerHTML = '<i class="bi bi-clipboard"></i>'; }, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('testNcBtn').addEventListener('click', async function () {
|
||||
const res = document.getElementById('testNcResult');
|
||||
res.textContent = 'Test en cours…';
|
||||
try {
|
||||
const r = await fetch('/api/nextcloud/browse');
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${d.error}</span>`;
|
||||
} else {
|
||||
res.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Connexion OK — ${d.items.length} élément(s) dans ${d.path}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('testHaBtn').addEventListener('click', async function () {
|
||||
const res = document.getElementById('testHaResult');
|
||||
res.textContent = 'Test en cours…';
|
||||
try {
|
||||
const r = await fetch('/api/ha/status');
|
||||
const d = await r.json();
|
||||
if (d.error) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${d.error}</span>`;
|
||||
} else {
|
||||
const count = Object.keys(d.printers).length;
|
||||
res.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Connexion OK — ${count} imprimante(s) avec préfixe HA</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
res.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle me-1"></i>${e.message}</span>`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -81,4 +81,107 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Gaspillage & Échecs ──────────────────────────────────────────────────── -->
|
||||
<div class="row g-4 mt-0">
|
||||
|
||||
<!-- Gaspillage par matière -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-trash3 text-danger me-2"></i>Filament gaspillé par matière
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if waste_by_material %}
|
||||
{% set max_waste = waste_by_material | map(attribute='wasted_g') | max %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Matière</th>
|
||||
<th class="text-end">Gaspillé</th>
|
||||
<th class="text-end text-muted small">Stock restant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in waste_by_material %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if m.color %}
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
||||
background:{{ m.color | css_color }};border:1px solid #ccc;
|
||||
vertical-align:middle;margin-right:5px"></span>
|
||||
{% endif %}
|
||||
<span class="fw-semibold">{{ m.name }}</span>
|
||||
{% if m.type %}<span class="badge bg-light text-dark ms-1 small">{{ m.type }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span class="text-danger fw-bold">{{ m.wasted_g }} g</span>
|
||||
{% if max_waste > 0 %}
|
||||
<div class="progress mt-1" style="height:3px">
|
||||
<div class="progress-bar bg-danger" style="width:{{ (m.wasted_g / max_waste * 100)|round(0) }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-muted small">{{ m.stock_g }} g</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-check-circle text-success fs-2 d-block mb-2"></i>
|
||||
Aucun gaspillage enregistré.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taux d'échec par imprimante -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-printer text-warning me-2"></i>Taux d'échec par imprimante
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if failure_by_printer %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Imprimante</th>
|
||||
<th class="text-center">Taux</th>
|
||||
<th class="text-end">Gaspillé</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in failure_by_printer %}
|
||||
{% set rate = (p.failed_slots / p.total_slots * 100) | round(1) %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ p.name }}</div>
|
||||
<div class="text-muted small">{{ p.failed_slots }}/{{ p.total_slots }} créneaux</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge {% if rate >= 20 %}bg-danger{% elif rate >= 10 %}bg-warning text-dark{% else %}bg-success{% endif %}">
|
||||
{{ rate }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end {% if p.wasted_g > 0 %}text-danger{% else %}text-muted{% endif %} fw-semibold">
|
||||
{{ p.wasted_g }} g
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-check-circle text-success fs-2 d-block mb-2"></i>
|
||||
Aucun échec enregistré.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user