Compare commits
34 Commits
ab3f8a5afa
..
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 | |||
| 0c9e04b6e7 | |||
| 614ea23a7c |
Binary file not shown.
@@ -9,6 +9,14 @@ services:
|
|||||||
- 3d-pricing-data:/data
|
- 3d-pricing-data:/data
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_PATH=/data/pricing.db
|
- 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:
|
volumes:
|
||||||
3d-pricing-data:
|
3d-pricing-data:
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
flask>=3.0.0
|
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@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">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<style>
|
<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; }
|
body { background: #f4f6f9; }
|
||||||
|
|
||||||
#sidebar {
|
#sidebar {
|
||||||
width: var(--sidebar-w); min-height: 100vh;
|
width: var(--sidebar-w); min-height: 100vh;
|
||||||
background: #1a1d23; position: fixed; top: 0; left: 0;
|
background: #1a1d23; position: fixed; top: 0; left: 0;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
|
transition: width .2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
#sidebar.collapsed { width: var(--sidebar-collapsed-w); }
|
||||||
|
|
||||||
#sidebar .brand {
|
#sidebar .brand {
|
||||||
padding: 1.25rem 1.25rem 1rem; color: #fff;
|
padding: 1rem 1.25rem; color: #fff;
|
||||||
font-weight: 700; font-size: 1.1rem;
|
font-weight: 700; font-size: 1.1rem;
|
||||||
border-bottom: 1px solid #2e323d;
|
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 .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 {
|
#sidebar nav a {
|
||||||
display: flex; align-items: center; gap: .65rem;
|
display: flex; align-items: center; gap: .65rem;
|
||||||
padding: .6rem 1.25rem; color: #9ba3af;
|
padding: .6rem 1.25rem; color: #9ba3af;
|
||||||
text-decoration: none; font-size: .875rem;
|
text-decoration: none; font-size: .875rem;
|
||||||
transition: background .15s, color .15s;
|
transition: background .15s, color .15s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
#sidebar nav a:hover, #sidebar nav a.active { background: #2e323d; color: #fff; }
|
#sidebar nav a:hover, #sidebar nav a.active { background: #2e323d; color: #fff; }
|
||||||
#sidebar nav a.active { border-left: 3px solid #ff6b35; }
|
#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 {
|
#sidebar nav .nav-section {
|
||||||
padding: .75rem 1.25rem .25rem; font-size: .7rem;
|
padding: .75rem 1.25rem .25rem; font-size: .7rem;
|
||||||
text-transform: uppercase; letter-spacing: .08em; color: #4b5563;
|
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 { margin-bottom: 1.5rem; }
|
||||||
.page-header h1 { font-size: 1.4rem; font-weight: 700; margin: 0; }
|
.page-header h1 { font-size: 1.4rem; font-weight: 700; margin: 0; }
|
||||||
|
|
||||||
@@ -61,47 +105,138 @@
|
|||||||
.stock-ok { color: #22c55e; }
|
.stock-ok { color: #22c55e; }
|
||||||
.stock-low { color: #f59e0b; }
|
.stock-low { color: #f59e0b; }
|
||||||
.stock-empty { color: #ef4444; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 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>
|
<nav>
|
||||||
<div class="nav-section">Navigation</div>
|
<div class="nav-section">Navigation</div>
|
||||||
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}">
|
<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint=='dashboard' %}active{% endif %}" title="Dashboard">
|
||||||
<i class="bi bi-speedometer2"></i> Dashboard
|
<i class="bi bi-speedometer2"></i><span class="nav-label"> Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('jobs') }}" class="{% if request.endpoint in ['jobs','job_detail'] %}active{% endif %}">
|
<a href="{{ url_for('jobs') }}" class="{% if request.endpoint in ['jobs','job_detail'] %}active{% endif %}" title="Commandes">
|
||||||
<i class="bi bi-printer"></i> Commandes
|
<i class="bi bi-printer"></i><span class="nav-label"> Commandes</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('new_job') }}" class="{% if request.endpoint=='new_job' %}active{% endif %}">
|
<a href="{{ url_for('new_job') }}" class="{% if request.endpoint=='new_job' %}active{% endif %}" title="Nouveau calcul">
|
||||||
<i class="bi bi-plus-circle"></i> Nouveau calcul
|
<i class="bi bi-plus-circle"></i><span class="nav-label"> Nouveau calcul</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('clients') }}" class="{% if 'client' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('clients') }}" class="{% if 'client' in request.endpoint %}active{% endif %}" title="Clients">
|
||||||
<i class="bi bi-building"></i> Clients
|
<i class="bi bi-building"></i><span class="nav-label"> Clients</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('materials') }}" class="{% if 'material' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('materials') }}" class="{% if 'material' in request.endpoint %}active{% endif %}" title="Matières">
|
||||||
<i class="bi bi-boxes"></i> Matières
|
<i class="bi bi-boxes"></i><span class="nav-label"> Matières</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}">
|
<a href="{{ url_for('profiles') }}" class="{% if 'profile' in request.endpoint %}active{% endif %}" title="Templates">
|
||||||
<i class="bi bi-bookmark-star"></i> Templates
|
<i class="bi bi-bookmark-star"></i><span class="nav-label"> Templates</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('planning') }}" class="{% if request.endpoint in ['planning','printers_page','working_schedule','calendar_blocks'] %}active{% endif %}">
|
<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-calendar3"></i> Planning
|
<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>
|
</a>
|
||||||
<div class="nav-section">Analyse</div>
|
<div class="nav-section">Analyse</div>
|
||||||
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}">
|
<a href="{{ url_for('stats') }}" class="{% if request.endpoint=='stats' %}active{% endif %}" title="Statistiques">
|
||||||
<i class="bi bi-bar-chart-line"></i> Statistiques
|
<i class="bi bi-bar-chart-line"></i><span class="nav-label"> Statistiques</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('export_csv') }}">
|
<a href="{{ url_for('export_csv') }}" title="Export CSV">
|
||||||
<i class="bi bi-download"></i> Export CSV
|
<i class="bi bi-download"></i><span class="nav-label"> Export CSV</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="nav-section">Config</div>
|
<div class="nav-section">Config</div>
|
||||||
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}">
|
<a href="{{ url_for('settings') }}" class="{% if request.endpoint=='settings' %}active{% endif %}" title="Paramètres">
|
||||||
<i class="bi bi-sliders"></i> 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>
|
</a>
|
||||||
</nav>
|
</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>
|
||||||
|
|
||||||
<div id="main">
|
<div id="main">
|
||||||
@@ -116,6 +251,70 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<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 %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,9 +14,15 @@
|
|||||||
<div class="card-header py-3">Ajouter une exception</div>
|
<div class="card-header py-3">Ajouter une exception</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<label class="form-label fw-semibold">Date</label>
|
<div class="col">
|
||||||
<input type="date" name="date" class="form-control" required>
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Type</label>
|
<label class="form-label fw-semibold">Type</label>
|
||||||
@@ -55,12 +61,17 @@
|
|||||||
{% if blocks %}
|
{% if blocks %}
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for b in blocks %}
|
{% for b in blocks %}
|
||||||
<tr>
|
<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>
|
<td>
|
||||||
{% if b.type == 'off' %}<span class="badge bg-danger">Off</span>
|
{% if b.type == 'off' %}<span class="badge bg-danger">Off</span>
|
||||||
{% elif b.type == 'tt' %}<span class="badge bg-primary">TT</span>
|
{% elif b.type == 'tt' %}<span class="badge bg-primary">TT</span>
|
||||||
@@ -89,3 +100,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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>
|
||||||
+860
-48
@@ -9,17 +9,39 @@
|
|||||||
</a>
|
</a>
|
||||||
<h1 class="mt-1">{{ job.name }}</h1>
|
<h1 class="mt-1">{{ job.name }}</h1>
|
||||||
</div>
|
</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) }}"
|
<form method="POST" action="{{ url_for('delete_job', id=job.id) }}"
|
||||||
onsubmit="return confirm('Supprimer cette commande ?')">
|
onsubmit="return confirm('Supprimer cette commande ?')">
|
||||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Supprimer</button>
|
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> Supprimer</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set qty = job.order_qty or 1 %}
|
||||||
|
{% set multi = qty > 1 %}
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header py-3">Informations</div>
|
<div class="card-header py-3">Informations</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-sm mb-0">
|
<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" style="width:45%">Client</th><td>{{ job.client_name or '—' }}</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Matiere</th>
|
<tr><th class="text-muted fw-normal">Matiere</th>
|
||||||
@@ -30,10 +52,39 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><th class="text-muted fw-normal">Fichier source</th>
|
<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">Date</th><td>{{ job.created_at[:10] }}</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Poids filament</th><td>{{ job.weight_g }} g</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">Duree impression</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
|
<tr><th class="text-muted fw-normal">Pieces / plateau</th><td>{{ job.pieces_per_plate or 1 }}</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal">Quantite commandee</th><td>{{ qty }} pièce{{ 's' if qty > 1 else '' }}</td></tr>
|
||||||
|
<tr><th class="text-muted fw-normal">Duree plateau</th><td>{{ job.print_time_s | fmt_time }}</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Coeff design</th><td>×{{ job.design_multiplier }}</td></tr>
|
<tr><th class="text-muted fw-normal">Coeff design</th><td>×{{ job.design_multiplier }}</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Marge brute</th><td>{{ job.gross_margin_pct }} %</td></tr>
|
<tr><th class="text-muted fw-normal">Marge brute</th><td>{{ job.gross_margin_pct }} %</td></tr>
|
||||||
<tr><th class="text-muted fw-normal">Remise</th><td>{{ job.discount_pct }} %</td></tr>
|
<tr><th class="text-muted fw-normal">Remise</th><td>{{ job.discount_pct }} %</td></tr>
|
||||||
@@ -47,6 +98,7 @@
|
|||||||
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
|
<tr><th class="text-muted fw-normal">Profil tarif</th><td>{{ job.pricing_profile_name }}</td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% if job.notes %}
|
{% if job.notes %}
|
||||||
<hr>
|
<hr>
|
||||||
<small class="text-muted">{{ job.notes }}</small>
|
<small class="text-muted">{{ job.notes }}</small>
|
||||||
@@ -57,93 +109,853 @@
|
|||||||
|
|
||||||
<div class="col-md-7">
|
<div class="col-md-7">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header py-3"><i class="bi bi-receipt me-2"></i>Decomposition du prix</div>
|
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-receipt me-2"></i>Decomposition du prix</span>
|
||||||
|
{% if multi %}
|
||||||
|
<span class="badge bg-secondary">{{ qty }} pièces commandées</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
||||||
<!-- Cout fixe -->
|
{# Macro pour une ligne à 1 ou 2 colonnes #}
|
||||||
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
|
{% macro brow(label, val_piece, cls='', precision=2) %}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Matiere{% if job.material_name %} <small class="text-secondary">{{ job.material_name }}</small>{% endif %}</span><span>{{ "%.4f"|format(job.material_cost) }} €</span></div>
|
<div class="breakdown-row {{ cls }}">
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Marge matiere</span><span>{{ "%.4f"|format(job.material_margin) }} €</span></div>
|
<span class="flex-fill">{{ label | safe }}</span>
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Usure machine</span><span>{{ "%.4f"|format(job.wear_cost) }} €</span></div>
|
<span class="text-end" style="min-width:85px">
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Electricite</span><span>{{ "%.4f"|format(job.electricity_cost) }} €</span></div>
|
{% if precision == 4 %}{{ "%.4f"|format(val_piece) }} €
|
||||||
{% set cout_f = job.cout_fixe or (job.material_cost + job.material_margin + job.wear_cost + job.electricity_cost) %}
|
{% else %}{{ "%.2f"|format(val_piece) }} €{% endif %}
|
||||||
<div class="breakdown-row subtotal">
|
</span>
|
||||||
<span class="fw-semibold">Total cout fixe</span>
|
{% if multi %}
|
||||||
<span class="fw-semibold">{{ "%.2f"|format(cout_f) }} €</span>
|
<span class="text-end text-muted" style="min-width:85px">
|
||||||
|
{% if precision == 4 %}{{ "%.2f"|format(val_piece * qty) }} €
|
||||||
|
{% else %}{{ "%.2f"|format(val_piece * qty) }} €{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- Partie variable -->
|
{# En-tête colonnes #}
|
||||||
<div class="breakdown-row section-header"><span>Partie variable</span></div>
|
{% if multi %}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Manutention</span><span>{{ "%.4f"|format(job.handling_cost) }} €</span></div>
|
<div class="d-flex justify-content-end small text-muted mb-2 border-bottom pb-1 gap-0">
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Design (×{{ job.design_multiplier }})</span><span>{{ "%.4f"|format(job.design_cost) }} €</span></div>
|
<span style="min-width:85px;text-align:right">/ pièce</span>
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Marge brute ({{ job.gross_margin_pct }}%)</span><span>{{ "%.2f"|format(job.margin_amount) }} €</span></div>
|
<span style="min-width:85px;text-align:right">/ commande ×{{ qty }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set cout_f = job.cout_fixe or (job.material_cost + job.material_margin + job.wear_cost + job.electricity_cost) %}
|
||||||
{% set tot_m = job.total_marge or (job.handling_cost + job.design_cost + job.margin_amount) %}
|
{% set tot_m = job.total_marge or (job.handling_cost + job.design_cost + job.margin_amount) %}
|
||||||
{% set prix_ttc = job.final_price / (1 - job.discount_pct / 100) if job.discount_pct < 100 else job.final_price %}
|
{% set prix_ttc = job.final_price / (1 - job.discount_pct / 100) if job.discount_pct < 100 else job.final_price %}
|
||||||
{% set prix_ht_net = prix_ttc - (job.tva_amount or 0) %}
|
{% 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 %}
|
{% 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>
|
||||||
|
|
||||||
|
<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">
|
<div class="breakdown-row subtotal">
|
||||||
<span class="fw-semibold">Total marge <small class="text-muted fw-normal">({{ marge_pct_ht }}% du HT)</small></span>
|
<span class="flex-fill fw-semibold">Total cout fixe</span>
|
||||||
<span class="fw-semibold">{{ "%.2f"|format(tot_m) }} €</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, '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>
|
</div>
|
||||||
|
|
||||||
<!-- Fiscal -->
|
<!-- Fiscal -->
|
||||||
<div class="breakdown-row section-header"><span>Fiscal</span></div>
|
<div class="breakdown-row section-header"><span>Fiscal</span></div>
|
||||||
<div class="breakdown-row">
|
{{ brow('Cotisations BIC vente', job.cotisations_amount or job.tax_amount or 0) }}
|
||||||
<span class="breakdown-muted ps-2">Cotisations BIC vente</span>
|
|
||||||
<span>{% if job.cotisations_amount %}{{ "%.2f"|format(job.cotisations_amount) }}{% else %}{{ "%.2f"|format(job.tax_amount or 0) }}{% endif %} €</span>
|
|
||||||
</div>
|
|
||||||
{% if job.vfl_amount and job.vfl_amount > 0 %}
|
{% if job.vfl_amount and job.vfl_amount > 0 %}
|
||||||
<div class="breakdown-row">
|
{{ brow('VFL impot', job.vfl_amount) }}
|
||||||
<span class="breakdown-muted ps-2">VFL impot</span>
|
|
||||||
<span>{{ "%.2f"|format(job.vfl_amount) }} €</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if job.other_taxes_amount and job.other_taxes_amount > 0 %}
|
{% if job.other_taxes_amount and job.other_taxes_amount > 0 %}
|
||||||
<div class="breakdown-row">
|
{{ brow('Autres taxes / CFE', job.other_taxes_amount) }}
|
||||||
<span class="breakdown-muted ps-2">Autres taxes / CFE</span>
|
|
||||||
<span>{{ "%.2f"|format(job.other_taxes_amount) }} €</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="breakdown-row total" style="border-top:none">
|
|
||||||
<span>Prix HT</span>
|
<!-- Prix HT -->
|
||||||
<span class="final-price">{{ "%.2f"|format(prix_ht_net) }} €</span>
|
<div class="breakdown-row" style="border-top:none">
|
||||||
|
<span class="fw-bold flex-fill">Prix HT</span>
|
||||||
|
<span class="final-price text-end" style="font-size:1.3rem;min-width:85px">{{ "%.2f"|format(prix_ht_net) }} €</span>
|
||||||
|
{% if multi %}<span class="final-price text-end" style="font-size:1.3rem;min-width:85px;opacity:.75">{{ "%.2f"|format(prix_ht_net * qty) }} €</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if job.tva_amount and job.tva_amount > 0 %}
|
{% if job.tva_amount and job.tva_amount > 0 %}
|
||||||
<div class="breakdown-row">
|
<div class="breakdown-row">
|
||||||
<span class="ps-2">TVA</span>
|
<span class="ps-2 flex-fill">TVA</span>
|
||||||
<span>{{ "%.2f"|format(job.tva_amount) }} €</span>
|
<span class="text-end" style="min-width:85px">{{ "%.2f"|format(job.tva_amount) }} €</span>
|
||||||
|
{% if multi %}<span class="text-end text-muted" style="min-width:85px">{{ "%.2f"|format(job.tva_amount * qty) }} €</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if job.discount_pct > 0 %}
|
{% if job.discount_pct > 0 %}
|
||||||
<div class="breakdown-row text-danger">
|
<div class="breakdown-row text-danger">
|
||||||
<span class="ps-2">Remise ({{ job.discount_pct }}%)</span>
|
<span class="ps-2 flex-fill">Remise ({{ job.discount_pct }}%)</span>
|
||||||
<span>−{{ "%.2f"|format(prix_ttc * job.discount_pct / 100) }} €</span>
|
<span class="text-end" style="min-width:85px">−{{ "%.2f"|format(prix_ttc * job.discount_pct / 100) }} €</span>
|
||||||
|
{% if multi %}<span class="text-end" style="min-width:85px">−{{ "%.2f"|format(prix_ttc * qty * job.discount_pct / 100) }} €</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Prix final -->
|
||||||
|
{% if multi %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2 pt-2" style="border-top:2px solid #dee2e6">
|
||||||
|
<span class="fw-bold">PRIX FINAL TTC</span>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<span class="final-price" style="font-size:1.4rem">{{ "%.2f"|format(job.final_price) }} €</span>
|
||||||
|
<span class="final-price" style="font-size:1.4rem;opacity:.7">× {{ qty }} = {{ "%.2f"|format(job.final_price * qty) }} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<div class="breakdown-row total">
|
<div class="breakdown-row total">
|
||||||
<span>PRIX FINAL TTC</span>
|
<span>PRIX FINAL TTC</span>
|
||||||
<span class="final-price">{{ "%.2f"|format(job.final_price) }} €</span>
|
<span class="final-price">{{ "%.2f"|format(job.final_price) }} €</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Barre de composition -->
|
<!-- Barre de composition -->
|
||||||
{% set fiscal = (job.cotisations_amount or job.tax_amount or 0) + (job.vfl_amount or 0) + (job.other_taxes_amount or 0) + (job.tva_amount or 0) %}
|
{% set fiscal = (job.cotisations_amount or job.tax_amount or 0) + (job.vfl_amount or 0) + (job.other_taxes_amount or 0) + (job.tva_amount or 0) %}
|
||||||
{% set total = job.final_price %}
|
{% if job.final_price > 0 %}
|
||||||
{% if total > 0 %}
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="d-flex justify-content-between small text-muted mb-1">
|
<div class="d-flex justify-content-between small text-muted mb-1">
|
||||||
<span>Cout fixe ({{ (cout_f/total*100)|round(1) }}%)</span>
|
<span>Cout fixe ({{ (cout_f/job.final_price*100)|round(1) }}%)</span>
|
||||||
<span>Marge ({{ (tot_m/total*100)|round(1) }}%)</span>
|
<span>Marge ({{ (tot_m/job.final_price*100)|round(1) }}%)</span>
|
||||||
<span>Fiscal ({{ (fiscal/total*100)|round(1) }}%)</span>
|
<span>Fiscal ({{ (fiscal/job.final_price*100)|round(1) }}%)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" style="height:18px;border-radius:6px">
|
<div class="progress" style="height:18px;border-radius:6px">
|
||||||
<div class="progress-bar bg-secondary" style="width:{{ (cout_f/total*100)|round(1) }}%"></div>
|
<div class="progress-bar bg-secondary" style="width:{{ (cout_f/job.final_price*100)|round(1) }}%"></div>
|
||||||
<div class="progress-bar" style="width:{{ (tot_m/total*100)|round(1) }}%;background:#ff6b35"></div>
|
<div class="progress-bar" style="width:{{ (tot_m/job.final_price*100)|round(1) }}%;background:#ff6b35"></div>
|
||||||
<div class="progress-bar bg-warning text-dark" style="width:{{ (fiscal/total*100)|round(1) }}%"></div>
|
<div class="progress-bar bg-warning text-dark" style="width:{{ (fiscal/job.final_price*100)|round(1) }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if jobs %}
|
{% if jobs %}
|
||||||
|
<div class="table-responsive">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th><th>Nom</th><th>Client</th><th>Poids</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</th><th>Remise</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>
|
<th class="text-end">Prix final</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -31,10 +32,12 @@
|
|||||||
<td class="text-muted small">{{ j.created_at[:10] }}</td>
|
<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><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>{{ j.client_name or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ j.plate_name or '—' }}</td>
|
||||||
<td>{{ j.weight_g }} g</td>
|
<td>{{ j.weight_g }} g</td>
|
||||||
<td>{{ j.print_time_s | fmt_time }}</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.design_multiplier }}</span></td>
|
||||||
<td><span class="badge bg-light text-dark">{{ j.gross_margin_pct }}%</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>
|
<td>
|
||||||
{% if j.discount_pct > 0 %}
|
{% if j.discount_pct > 0 %}
|
||||||
<span class="badge bg-danger">-{{ j.discount_pct }}%</span>
|
<span class="badge bg-danger">-{{ j.discount_pct }}%</span>
|
||||||
@@ -42,7 +45,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }} €</td>
|
<td class="text-end fw-bold" style="color:#ff6b35">{{ j.final_price }} €</td>
|
||||||
<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>
|
<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) }}"
|
<form method="POST" action="{{ url_for('delete_job', id=j.id) }}"
|
||||||
onsubmit="return confirm('Supprimer ?')">
|
onsubmit="return confirm('Supprimer ?')">
|
||||||
@@ -54,6 +64,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center text-muted py-5">
|
<div class="text-center text-muted py-5">
|
||||||
<i class="bi bi-inbox fs-1"></i>
|
<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 '' }}"
|
value="{{ material.brand if material else '' }}"
|
||||||
placeholder="Bambu, eSUN, Polymaker…">
|
placeholder="Bambu, eSUN, Polymaker…">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-12">
|
||||||
<label class="form-label fw-semibold">Couleur</label>
|
<label class="form-label fw-semibold">Couleur</label>
|
||||||
<input type="text" name="color" class="form-control"
|
<!-- Valeur soumise (hex) -->
|
||||||
value="{{ material.color if material else '' }}"
|
<input type="hidden" name="color" id="colorValue"
|
||||||
placeholder="Rouge, #FF0000…">
|
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>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label fw-semibold">Prix (€/kg) *</label>
|
<label class="form-label fw-semibold">Prix (€/kg) *</label>
|
||||||
@@ -98,3 +121,126 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block content %}
|
||||||
<div class="page-header d-flex justify-content-between align-items-center">
|
<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>
|
<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">
|
<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
|
<i class="bi bi-plus-lg me-1"></i>Ajouter une matière
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if materials %}
|
{% 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">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nom</th><th>Type</th><th>Couleur</th>
|
<th data-col="name" class="sortable-th" style="cursor:pointer">
|
||||||
<th>Prix/kg</th><th>Stock</th><th>Commandes</th><th>Utilisé total</th><th></th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="materialsBody">
|
||||||
{% for m in materials %}
|
{% for m in materials %}
|
||||||
{% set stock_pct = [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min %}
|
{% 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 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><span class="badge bg-secondary">{{ m.type }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if m.color %}
|
{% if m.color %}
|
||||||
<span class="d-inline-flex align-items-center gap-1">
|
<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 }}
|
{{ m.color }}
|
||||||
</span>
|
</span>
|
||||||
{% else %}—{% endif %}
|
{% else %}—{% endif %}
|
||||||
@@ -49,24 +121,29 @@
|
|||||||
{% if m.stock_g <= 0 %}bg-danger
|
{% if m.stock_g <= 0 %}bg-danger
|
||||||
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
|
{% elif m.stock_g <= m.low_stock_threshold_g %}bg-warning
|
||||||
{% else %}bg-success{% endif %}"
|
{% else %}bg-success{% endif %}"
|
||||||
style="width:{{ [[(m.stock_g / 1000 * 100)|round(0)|int, 0]|max, 100]|min }}%">
|
style="width:{{ stock_pct }}%">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ m.job_count }}</td>
|
<td>{{ m.job_count }}</td>
|
||||||
<td>{{ m.total_used_g }} g</td>
|
<td>{{ m.total_used_g }} g</td>
|
||||||
<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"
|
<button class="btn btn-sm btn-outline-success py-0" data-bs-toggle="modal"
|
||||||
data-bs-target="#restockModal{{ m.id }}">
|
data-bs-target="#restockModal{{ m.id }}" title="Réapprovisionner">
|
||||||
<i class="bi bi-plus-lg"></i> Stock
|
<i class="bi bi-plus-lg"></i>
|
||||||
</button>
|
</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>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</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) }}"
|
<form method="POST" action="{{ url_for('delete_material', id=m.id) }}"
|
||||||
onsubmit="return confirm('Supprimer cette matière ?')">
|
onsubmit="return confirm('Supprimer {{ m.name }} ?')">
|
||||||
<button class="btn btn-sm btn-outline-danger py-0"><i class="bi bi-trash"></i></button>
|
<button class="btn btn-sm btn-outline-danger py-0" title="Supprimer"><i class="bi bi-trash"></i></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -106,6 +183,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 %}
|
{% else %}
|
||||||
<div class="text-center text-muted py-5">
|
<div class="text-center text-muted py-5">
|
||||||
<i class="bi bi-boxes fs-1"></i>
|
<i class="bi bi-boxes fs-1"></i>
|
||||||
@@ -118,3 +201,151 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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>
|
||||||
+524
-91
@@ -9,21 +9,34 @@
|
|||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
|
|
||||||
<!-- Import 3mf -->
|
<!-- Import 3mf (collapsible) -->
|
||||||
<div class="card mb-3 border border-warning border-opacity-50">
|
<div class="card mb-3 border border-warning border-opacity-50">
|
||||||
<div class="card-header py-3">
|
<div class="card-header py-2 d-flex align-items-center" role="button"
|
||||||
<i class="bi bi-file-earmark-zip text-warning me-2"></i>Import Bambu Studio (.3mf)
|
id="bambuToggle" style="cursor:pointer;user-select:none"
|
||||||
<span class="badge bg-warning text-dark ms-1">Optionnel</span>
|
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>
|
||||||
|
<div class="collapse" id="bambuBody">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<input type="file" id="file3mf" accept=".3mf" class="form-control" style="max-width:320px">
|
<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()">
|
<button type="button" class="btn btn-outline-warning" onclick="parse3mf()">
|
||||||
<i class="bi bi-magic me-1"></i>Extraire
|
<i class="bi bi-magic me-1"></i>Extraire
|
||||||
</button>
|
</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>
|
<span id="parseStatus" class="text-muted small"></span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,35 +47,71 @@
|
|||||||
<div class="card-header py-3">Donnees impression</div>
|
<div class="card-header py-3">Donnees impression</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3 mb-3">
|
<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">
|
<div class="col-md-8">
|
||||||
<label class="form-label fw-semibold">Nom de la commande</label>
|
<label class="form-label fw-semibold">Nom de la commande</label>
|
||||||
<input type="text" name="name" id="jobName" class="form-control" required
|
<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>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Fichier source</label>
|
<label class="form-label fw-semibold">Fichier source</label>
|
||||||
|
<div class="input-group">
|
||||||
<input type="text" name="source_file" id="sourceFile" class="form-control"
|
<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>
|
</div>
|
||||||
<div class="row g-3 mb-3">
|
<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>
|
<label class="form-label fw-semibold">Client</label>
|
||||||
<select name="client_id" id="clientSelect" class="form-select">
|
<select name="client_id" id="clientSelect" class="form-select">
|
||||||
<option value="">Sans client</option>
|
<option value="">Sans client</option>
|
||||||
{% for c in clients %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<label class="form-label fw-semibold">Matiere (filament)</label>
|
||||||
<select name="material_id" id="materialSelect" class="form-select">
|
<select name="material_id" id="materialSelect" class="form-select">
|
||||||
<option value="">Prix global (parametres)</option>
|
<option value="">Prix global (parametres)</option>
|
||||||
{% for m in materials %}
|
{% for m in materials %}
|
||||||
<option value="{{ m.id }}" data-price="{{ m.price_per_kg }}"
|
<option value="{{ m.id }}" data-price="{{ m.price_per_kg }}"
|
||||||
data-stock="{{ m.stock_g }}" data-threshold="{{ m.low_stock_threshold_g }}"
|
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
|
{{ 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 %}
|
{% if m.stock_g <= 0 %}[RUPTURE]{% elif m.stock_g <= m.low_stock_threshold_g %}[stock bas]{% endif %}
|
||||||
</option>
|
</option>
|
||||||
@@ -73,18 +122,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Poids filament (g)</label>
|
<label class="form-label fw-semibold">Poids plateau (g)</label>
|
||||||
<input type="number" name="weight_g" id="weight_g" class="form-control"
|
<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"
|
||||||
<div class="form-text">Champ Modele dans Bambu</div>
|
value="{{ prefill.weight_g if prefill else '' }}">
|
||||||
|
<div class="form-text">Poids total du plateau Bambu (toutes pièces)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Heures</label>
|
<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>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label fw-semibold">Minutes</label>
|
<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 class="form-text">Temps total Bambu</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,16 +144,25 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Pièces par plateau</label>
|
<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"
|
<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 class="form-text">Combien de pièces tiennent sur un plateau.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-semibold">Quantité commandée</label>
|
<label class="form-label fw-semibold">Quantité commandée</label>
|
||||||
<input type="number" name="order_qty" id="order_qty" class="form-control"
|
<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 class="form-text">Nombre total de pièces à produire.</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,7 +183,10 @@
|
|||||||
<select name="machine_profile_id" id="machineProfileSelect" class="form-select form-select-sm">
|
<select name="machine_profile_id" id="machineProfileSelect" class="form-select form-select-sm">
|
||||||
<option value="">— Parametres globaux —</option>
|
<option value="">— Parametres globaux —</option>
|
||||||
{% for p in machine_profiles %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +197,10 @@
|
|||||||
<select name="handling_profile_id" id="handlingProfileSelect" class="form-select form-select-sm">
|
<select name="handling_profile_id" id="handlingProfileSelect" class="form-select form-select-sm">
|
||||||
<option value="">— Parametres globaux —</option>
|
<option value="">— Parametres globaux —</option>
|
||||||
{% for p in handling_profiles %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +211,8 @@
|
|||||||
<select name="material_profile_id" id="materialProfileSelect" class="form-select form-select-sm">
|
<select name="material_profile_id" id="materialProfileSelect" class="form-select form-select-sm">
|
||||||
<option value="">— Parametres globaux —</option>
|
<option value="">— Parametres globaux —</option>
|
||||||
{% for p in material_profiles %}
|
{% 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 }}%)
|
{{ p.name }} ({{ p.material_margin_pct }}%)
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -159,7 +227,8 @@
|
|||||||
{% for p in pricing_profiles %}
|
{% for p in pricing_profiles %}
|
||||||
<option value="{{ p.id }}"
|
<option value="{{ p.id }}"
|
||||||
data-dm="{{ p.design_multiplier }}"
|
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 }}
|
{{ p.name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -186,7 +255,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" name="design_multiplier" id="design_multiplier"
|
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="form-label fw-semibold d-flex justify-content-between">
|
<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>
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setMargin(35)">35%</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" name="gross_margin_pct" id="gross_margin_pct" class="form-range"
|
<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">
|
<div class="d-flex justify-content-between" style="font-size:.75rem;color:#6b7280">
|
||||||
<span>0%</span><span>100%</span>
|
<span>0%</span><span>100%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,18 +278,24 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Remise client (%)</label>
|
<label class="form-label fw-semibold">Remise client (%)</label>
|
||||||
<input type="number" name="discount_pct" id="discount_pct" class="form-control"
|
<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>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Notes internes</label>
|
<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>
|
</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
|
<i class="bi bi-save me-2"></i>Enregistrer la commande
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -237,35 +314,164 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<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() {
|
function parse3mf() {
|
||||||
const file = document.getElementById('file3mf').files[0];
|
const file = document.getElementById('file3mf').files[0];
|
||||||
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
if (!file) { alert('Selectionnez un fichier .3mf'); return; }
|
||||||
const status = document.getElementById('parseStatus');
|
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();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
fetch('/api/parse-3mf', {method:'POST', body:fd})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.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) {
|
if (d.filename) {
|
||||||
document.getElementById('sourceFile').value = d.filename;
|
document.getElementById('sourceFile').value = d.filename;
|
||||||
if (!document.getElementById('jobName').value)
|
if (!document.getElementById('jobName').value)
|
||||||
document.getElementById('jobName').value = d.filename;
|
document.getElementById('jobName').value = d.filename;
|
||||||
}
|
}
|
||||||
status.textContent = filled.length ? 'Extrait: ' + filled.join(', ') : 'Donnees non trouvees';
|
// ── Fichier non slicé ──
|
||||||
status.style.color = filled.length ? '#22c55e' : '#f59e0b';
|
if (d.warning) {
|
||||||
recalc();
|
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 ────────────────────────────────────────────────────────
|
// ── Coefficient design ────────────────────────────────────────────────────────
|
||||||
@@ -417,8 +623,11 @@ function fmt2(n) { return parseFloat(n).toFixed(2) + ' €'; }
|
|||||||
function renderBreakdown(d, body) {
|
function renderBreakdown(d, body) {
|
||||||
const dm = parseFloat(body.design_multiplier).toFixed(2);
|
const dm = parseFloat(body.design_multiplier).toFixed(2);
|
||||||
const mgn = parseFloat(body.gross_margin_pct).toFixed(1);
|
const mgn = parseFloat(body.gross_margin_pct).toFixed(1);
|
||||||
|
const qty = body.order_qty || 1;
|
||||||
|
const ppp = body.pieces_per_plate || 1;
|
||||||
|
const multi = qty > 1;
|
||||||
|
|
||||||
// Nom du materiau selectionne
|
// Nom du materiau
|
||||||
const matSel = document.getElementById('materialSelect');
|
const matSel = document.getElementById('materialSelect');
|
||||||
const matOpt = matSel.options[matSel.selectedIndex];
|
const matOpt = matSel.options[matSel.selectedIndex];
|
||||||
const matName = matOpt.value ? (matOpt.dataset.name || matOpt.text.split('—')[0].trim()) : 'global';
|
const matName = matOpt.value ? (matOpt.dataset.name || matOpt.text.split('—')[0].trim()) : 'global';
|
||||||
@@ -427,46 +636,61 @@ function renderBreakdown(d, body) {
|
|||||||
const tvaLabel = d._tva_pct > 0 ? `TVA (${d._tva_pct}%)` : `TVA — franchise en base`;
|
const tvaLabel = d._tva_pct > 0 ? `TVA (${d._tva_pct}%)` : `TVA — franchise en base`;
|
||||||
const tvaClass = d._tva_pct > 0 ? '' : 'text-muted fst-italic';
|
const tvaClass = d._tva_pct > 0 ? '' : 'text-muted fst-italic';
|
||||||
|
|
||||||
// VFL
|
// Helper : génère une ligne avec 1 ou 2 colonnes de valeurs
|
||||||
const vflRow = d._vfl_pct > 0
|
// cls : classes CSS supplémentaires sur le div
|
||||||
? `<div class="breakdown-row"><span class="breakdown-muted ps-2">VFL impot (${d._vfl_pct}%)</span><span>${fmt2(d.vfl_amount)}</span></div>` : '';
|
function row(label, pieceVal, extraCls = '', isFmt4 = false) {
|
||||||
|
const fmtFn = isFmt4 ? fmt : fmt2;
|
||||||
|
const p = fmtFn(pieceVal);
|
||||||
|
const o = multi ? `<span class="text-end" style="min-width:80px">${fmt2(pieceVal * qty)}</span>` : '';
|
||||||
|
return `<div class="breakdown-row${extraCls ? ' '+extraCls : ''}">
|
||||||
|
<span class="flex-fill">${label}</span>
|
||||||
|
<span class="text-end" style="min-width:80px">${p}</span>
|
||||||
|
${o}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
function rowFiscal(label, pieceVal, extraCls = '') {
|
||||||
|
const p = fmt2(pieceVal);
|
||||||
|
const o = multi ? `<span class="text-end" style="min-width:80px">${fmt2(pieceVal * qty)}</span>` : '';
|
||||||
|
return `<div class="breakdown-row${extraCls ? ' '+extraCls : ''}">
|
||||||
|
<span class="flex-fill">${label}</span>
|
||||||
|
<span class="text-end" style="min-width:80px">${p}</span>
|
||||||
|
${o}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Autres taxes
|
// Alerte plateau incomplet
|
||||||
const otherRow = d._other_taxes_pct > 0
|
|
||||||
? `<div class="breakdown-row"><span class="breakdown-muted ps-2">Autres taxes / CFE (${d._other_taxes_pct}%)</span><span>${fmt2(d.other_taxes_amount)}</span></div>` : '';
|
|
||||||
|
|
||||||
// Remise
|
|
||||||
const disRow = body.discount_pct > 0
|
|
||||||
? `<div class="breakdown-row text-danger"><span class="ps-2">Remise (${body.discount_pct}%)</span><span>-${fmt2(d.prix_ttc * body.discount_pct / 100)}</span></div>` : '';
|
|
||||||
|
|
||||||
// Multi-piece header
|
|
||||||
const qty = body.order_qty || 1;
|
|
||||||
const ppp = body.pieces_per_plate || 1;
|
|
||||||
const nbPlateaux = Math.ceil(qty / ppp);
|
const nbPlateaux = Math.ceil(qty / ppp);
|
||||||
const lastPlate = qty % ppp;
|
const lastPlate = qty % ppp;
|
||||||
const priceOrder = d.price_order || (d.final_price * qty);
|
|
||||||
let plateauAlert = '';
|
let plateauAlert = '';
|
||||||
if (lastPlate !== 0) {
|
if (lastPlate !== 0) {
|
||||||
plateauAlert = `<div class="alert alert-warning py-2 mb-2 small">
|
plateauAlert = `<div class="alert alert-warning py-2 mb-2 small">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
Dernier plateau incomplet : <strong>${lastPlate}/${ppp} pièces</strong>.
|
Dernier plateau incomplet : <strong>${lastPlate}/${ppp} pièces</strong> —
|
||||||
Vous pourriez imprimer jusqu'à <strong>${nbPlateaux * ppp}</strong> pièces pour le même coût fixe.
|
vous pourriez imprimer <strong>${nbPlateaux * ppp}</strong> pièces pour le même coût fixe.
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prix header (1 ou 2 colonnes)
|
// En-tête colonnes (multi uniquement)
|
||||||
const multiPriceHeader = qty > 1
|
const colHeader = multi
|
||||||
? `<div class="d-flex justify-content-between align-items-center mb-3">
|
? `<div class="d-flex justify-content-end gap-0 small text-muted mb-1 border-bottom pb-1">
|
||||||
<div class="text-center flex-fill border-end">
|
<span style="min-width:80px;text-align:right">/ pièce</span>
|
||||||
<div class="small text-muted">Prix / pièce</div>
|
<span style="min-width:80px;text-align:right">/ commande ×${qty}</span>
|
||||||
<div class="fw-bold fs-5" style="color:#ff6b35">${fmt2(d.final_price)}</div>
|
</div>` : '';
|
||||||
</div>
|
|
||||||
<div class="text-center flex-fill">
|
// VFL / autres taxes
|
||||||
<div class="small text-muted">Prix commande (×${qty})</div>
|
const vflRow = d._vfl_pct > 0 ? rowFiscal(`<span class="breakdown-muted ps-2">VFL impot (${d._vfl_pct}%)</span>`, d.vfl_amount) : '';
|
||||||
<div class="fw-bold fs-5" style="color:#ff6b35">${fmt2(priceOrder)}</div>
|
const otherRow = d._other_taxes_pct > 0 ? rowFiscal(`<span class="breakdown-muted ps-2">Autres taxes / CFE (${d._other_taxes_pct}%)</span>`, d.other_taxes_amount) : '';
|
||||||
</div>
|
|
||||||
</div>`
|
// Remise (sur prix TTC/pièce)
|
||||||
: '';
|
let disRow = '';
|
||||||
|
if (body.discount_pct > 0) {
|
||||||
|
const remisePiece = d.prix_ttc * body.discount_pct / 100;
|
||||||
|
const o = multi ? `<span class="text-end text-danger" style="min-width:80px">-${fmt2(remisePiece * qty)}</span>` : '';
|
||||||
|
disRow = `<div class="breakdown-row text-danger">
|
||||||
|
<span class="flex-fill">Remise (${body.discount_pct}%)</span>
|
||||||
|
<span class="text-end" style="min-width:80px">-${fmt2(remisePiece)}</span>${o}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Capacité
|
// Capacité
|
||||||
const cap = d.capacity || {};
|
const cap = d.capacity || {};
|
||||||
@@ -479,35 +703,244 @@ function renderBreakdown(d, body) {
|
|||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / semaine</span><span>${cap.pieces_per_week||'—'}</span></div>
|
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / semaine</span><span>${cap.pieces_per_week||'—'}</span></div>
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / mois (30j)</span><span>${cap.pieces_per_month||'—'}</span></div>`;
|
<div class="breakdown-row"><span class="breakdown-muted ps-2">Pieces / mois (30j)</span><span>${cap.pieces_per_month||'—'}</span></div>`;
|
||||||
|
|
||||||
document.getElementById('breakdown').innerHTML = plateauAlert + multiPriceHeader + `
|
// Prix finaux
|
||||||
|
const pricePiece = d.final_price;
|
||||||
|
const priceOrder = pricePiece * qty;
|
||||||
|
const finalLabel = multi
|
||||||
|
? `<div class="d-flex justify-content-between align-items-center mt-2 pt-2" style="border-top:2px solid #dee2e6">
|
||||||
|
<span class="fw-bold">PRIX FINAL TTC</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<span class="final-price" style="font-size:1.4rem">${fmt2(pricePiece)}</span>
|
||||||
|
<span class="final-price" style="font-size:1.4rem;opacity:.7">× ${qty} = ${fmt2(priceOrder)}</span>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: `<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(pricePiece)}</span></div>`;
|
||||||
|
|
||||||
|
document.getElementById('breakdown').innerHTML = plateauAlert + colHeader + `
|
||||||
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
|
<div class="breakdown-row section-header"><span>Cout fixe</span></div>
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Matiere <small class="text-secondary">${matName}</small></span><span>${fmt(d.material_cost)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Matiere <small class="text-secondary">${matName}</small></span>`, d.material_cost, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Marge matiere (${d._material_margin_pct}%)</span><span>${fmt(d.material_margin)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Marge matiere (${d._material_margin_pct}%)</span>`, d.material_margin, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Usure — imprimante</span><span>${fmt(d.printer_wear)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Usure — imprimante</span>`, d.printer_wear, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Usure — buse</span><span>${fmt(d.nozzle_wear)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Usure — buse</span>`, d.nozzle_wear, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Usure — plateau</span><span>${fmt(d.plate_wear)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Usure — plateau</span>`, d.plate_wear, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Electricite <small class="text-secondary">${parseFloat(d.kwh_used).toFixed(3)} kWh × ${d._elec_price} €</small></span><span>${fmt(d.electricity_cost)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Electricite <small class="text-secondary">${parseFloat(d.kwh_used).toFixed(3)} kWh × ${d._elec_price} €</small></span>`, d.electricity_cost, '', true)}
|
||||||
<div class="breakdown-row subtotal"><span class="fw-semibold">Total cout fixe</span><span class="fw-semibold">${fmt2(d.cout_fixe)}</span></div>
|
${row(`<span class="fw-semibold">Total cout fixe</span>`, d.cout_fixe, 'subtotal')}
|
||||||
|
|
||||||
<div class="breakdown-row section-header"><span>Partie variable</span></div>
|
<div class="breakdown-row section-header"><span>Partie variable</span></div>
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Manutention <small class="text-secondary">${d._handling_minutes} min × ${d._handling_rate} €/h</small></span><span>${fmt(d.handling_cost)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Manutention <small class="text-secondary">${d._handling_minutes} min × ${d._handling_rate} €/h</small></span>`, d.handling_cost, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Design (x${dm})</span><span>${fmt(d.design_cost)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Design (x${dm})</span>`, d.design_cost, '', true)}
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Marge brute (${mgn}%)</span><span>${fmt2(d.margin_amount)}</span></div>
|
${row(`<span class="breakdown-muted ps-2">Marge brute (${mgn}%)</span>`, d.margin_amount)}
|
||||||
<div class="breakdown-row subtotal"><span class="fw-semibold">Total marge <small class="text-muted fw-normal">(${d.marge_pct_on_ht}% du HT)</small></span><span class="fw-semibold">${fmt2(d.total_marge)}</span></div>
|
${row(`<span class="fw-semibold">Total marge <small class="text-muted fw-normal">(${d.marge_pct_on_ht}% du HT)</small></span>`, d.total_marge, 'subtotal')}
|
||||||
|
|
||||||
<div class="breakdown-row section-header"><span>Fiscal</span></div>
|
<div class="breakdown-row section-header"><span>Fiscal</span></div>
|
||||||
<div class="breakdown-row"><span class="breakdown-muted ps-2">Cotisations BIC vente (${d._cot_pct}%)</span><span>${fmt2(d.cotisations_amount)}</span></div>
|
${rowFiscal(`<span class="breakdown-muted ps-2">Cotisations BIC vente (${d._cot_pct}%)</span>`, d.cotisations_amount)}
|
||||||
${vflRow}
|
${vflRow}${otherRow}
|
||||||
${otherRow}
|
<div class="breakdown-row" style="border-top:none">
|
||||||
<div class="breakdown-row total" style="border-top:none"><span>Prix HT</span><span class="final-price">${fmt2(d.prix_ht_net)}</span></div>
|
<span class="fw-bold">Prix HT</span>
|
||||||
<div class="breakdown-row ${tvaClass}"><span class="ps-2">${tvaLabel}</span><span>${d._tva_pct > 0 ? fmt2(d.tva_amount) : ''}</span></div>
|
<span class="final-price" style="font-size:1.3rem;text-align:right;min-width:80px">${fmt2(d.prix_ht_net)}</span>
|
||||||
|
${multi ? `<span class="final-price" style="font-size:1.3rem;text-align:right;min-width:80px;opacity:.75">${fmt2(d.prix_ht_net * qty)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="breakdown-row ${tvaClass}">
|
||||||
|
<span class="ps-2">${tvaLabel}</span>
|
||||||
|
<span class="text-end" style="min-width:80px">${d._tva_pct > 0 ? fmt2(d.tva_amount) : ''}</span>
|
||||||
|
${multi && d._tva_pct > 0 ? `<span class="text-end" style="min-width:80px">${fmt2(d.tva_amount * qty)}</span>` : (multi ? '<span style="min-width:80px"></span>' : '')}
|
||||||
|
</div>
|
||||||
${disRow}
|
${disRow}
|
||||||
<div class="breakdown-row total"><span>PRIX FINAL TTC</span><span class="final-price">${fmt2(d.final_price)}</span></div>
|
${finalLabel}
|
||||||
` + capBlock;
|
` + capBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
// Init — utiliser les valeurs du champ (prefill ou défaut)
|
||||||
updateDMLabel(1.80);
|
const _initDM = parseFloat(document.getElementById('design_multiplier').value) || 1.80;
|
||||||
updateMarginDisplay(27.5);
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+408
-26
@@ -49,6 +49,7 @@
|
|||||||
{% if unscheduled %}
|
{% if unscheduled %}
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
{% for j in unscheduled %}
|
{% for j in unscheduled %}
|
||||||
|
{% set missing = j.total_plates_needed - j.scheduled_plates %}
|
||||||
<div class="list-group-item py-2">
|
<div class="list-group-item py-2">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<div>
|
<div>
|
||||||
@@ -58,6 +59,15 @@
|
|||||||
{% if j.order_qty > 1 %} · ×{{ j.order_qty }} pièces{% endif %}
|
{% if j.order_qty > 1 %} · ×{{ j.order_qty }} pièces{% endif %}
|
||||||
{% if j.client_name %} · {{ j.client_name }}{% endif %}
|
{% if j.client_name %} · {{ j.client_name }}{% endif %}
|
||||||
</div>
|
</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>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<button class="btn btn-sm btn-outline-success py-0"
|
<button class="btn btn-sm btn-outline-success py-0"
|
||||||
@@ -92,6 +102,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Modal planification -->
|
<!-- Modal planification -->
|
||||||
@@ -123,13 +148,25 @@
|
|||||||
<label class="form-label fw-semibold">Date et heure de lancement</label>
|
<label class="form-label fw-semibold">Date et heure de lancement</label>
|
||||||
<input type="datetime-local" id="modalStart" class="form-control">
|
<input type="datetime-local" id="modalStart" class="form-control">
|
||||||
</div>
|
</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">
|
<div id="modalSuggestion" class="alert alert-success py-2 small d-none">
|
||||||
<i class="bi bi-magic me-1"></i><span id="modalSuggestionText"></span>
|
<i class="bi bi-magic me-1"></i><span id="modalSuggestionText"></span>
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
|
<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
|
<i class="bi bi-save me-1"></i>Planifier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,9 +185,20 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="hidden" id="slotModalId">
|
<input type="hidden" id="slotModalId">
|
||||||
<p id="slotModalInfo" class="text-muted small"></p>
|
<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">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Statut</label>
|
<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="planned">Planifié</option>
|
||||||
<option value="printing">En cours</option>
|
<option value="printing">En cours</option>
|
||||||
<option value="done">Terminé ✓</option>
|
<option value="done">Terminé ✓</option>
|
||||||
@@ -159,9 +207,19 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" id="slotModalNoteGroup">
|
<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">
|
<input type="text" id="slotModalNote" class="form-control" placeholder="Ex: couche 45 — sous-extrusion">
|
||||||
</div>
|
</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>
|
||||||
<div class="modal-footer d-flex justify-content-between">
|
<div class="modal-footer d-flex justify-content-between">
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteSlot()">
|
<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">
|
<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 src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const HA_POLL_MS = {{ (settings.get('ha_poll_interval', '60') | int) * 1000 }};
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
planned: '#3b82f6',
|
planned: '#3b82f6',
|
||||||
printing: '#f59e0b',
|
printing: '#f59e0b',
|
||||||
@@ -194,6 +254,58 @@ const STATUS_LABELS = {
|
|||||||
|
|
||||||
let timeline, items, groups;
|
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() {
|
async function initTimeline() {
|
||||||
const [slotsRes, availRes] = await Promise.all([
|
const [slotsRes, availRes] = await Promise.all([
|
||||||
fetch('/api/slots').then(r => r.json()),
|
fetch('/api/slots').then(r => r.json()),
|
||||||
@@ -218,24 +330,40 @@ async function initTimeline() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Print slots
|
// 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,
|
id: s.id,
|
||||||
group: s.printer_id,
|
group: s.printer_id,
|
||||||
start: s.planned_start,
|
start: s.planned_start,
|
||||||
end: s.planned_end,
|
end: s.planned_end,
|
||||||
content: `<span style="font-size:.7rem">${s.job_name}</span>`,
|
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: `${s.job_name}${s.client_name ? ' — ' + s.client_name : ''}<br>${STATUS_LABELS[s.status] || s.status}`,
|
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`,
|
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();
|
const now = new Date();
|
||||||
timeline = new vis.Timeline(
|
timeline = new vis.Timeline(
|
||||||
document.getElementById('timeline'),
|
document.getElementById('timeline'),
|
||||||
items, groups,
|
items, groups,
|
||||||
{
|
{
|
||||||
start: now,
|
start: new Date(now.getTime() - 12 * 3600000),
|
||||||
end: new Date(now.getTime() + 14 * 86400000),
|
end: new Date(now.getTime() + 14 * 86400000),
|
||||||
groupOrder: 'id',
|
groupOrder: 'id',
|
||||||
selectable: true,
|
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>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 15);
|
const upcoming = slots.filter(s => s.status !== 'cancelled').slice(0, 30);
|
||||||
el.innerHTML = `<table class="table table-sm table-hover mb-0">
|
|
||||||
<thead class="table-light"><tr>
|
// Pre-compute per-job stats using job_total_ignored from API (covers all slots, even old ones)
|
||||||
<th class="small">Début</th><th class="small">Job</th>
|
const jobMeta = {};
|
||||||
<th class="small">Machine</th><th class="small">Statut</th>
|
for (const s of upcoming) {
|
||||||
</tr></thead>
|
if (!jobMeta[s.job_id]) {
|
||||||
<tbody>
|
jobMeta[s.job_id] = {
|
||||||
${upcoming.map(s => `<tr style="cursor:pointer" onclick="openSlotModal(${JSON.stringify(s).replace(/"/g,'"')})">
|
name: s.job_name,
|
||||||
<td class="small">${formatDt(s.planned_start)}</td>
|
totalPlates: s.total_plates,
|
||||||
<td class="small fw-semibold">${s.job_name}${s.client_name ? '<br><span class=\'text-muted fw-normal\'>' + s.client_name + '</span>' : ''}</td>
|
totalIgnored: s.job_total_ignored, // sum across ALL slots of job
|
||||||
<td class="small">${s.printer_name}</td>
|
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>
|
<td><span class="badge" style="background:${STATUS_COLORS[s.status]}">${STATUS_LABELS[s.status]||s.status}</span></td>
|
||||||
</tr>`).join('')}
|
</tr>`;
|
||||||
</tbody></table>`;
|
}
|
||||||
|
|
||||||
|
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) {
|
function formatDt(iso) {
|
||||||
@@ -292,11 +534,33 @@ function formatDt(iso) {
|
|||||||
d.toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'});
|
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) {
|
function openScheduleModal(jobId, printTime, jobName) {
|
||||||
document.getElementById('modalJobId').value = jobId;
|
document.getElementById('modalJobId').value = jobId;
|
||||||
document.getElementById('modalPrintTime').value = printTime;
|
document.getElementById('modalPrintTime').value = printTime;
|
||||||
document.getElementById('modalJobName').textContent = jobName;
|
document.getElementById('modalJobName').textContent = jobName;
|
||||||
document.getElementById('modalSuggestion').classList.add('d-none');
|
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
|
// Default: now rounded to next hour
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(0, 0, 0);
|
now.setMinutes(0, 0, 0);
|
||||||
@@ -329,20 +593,34 @@ async function saveSlot() {
|
|||||||
const jobId = parseInt(document.getElementById('modalJobId').value);
|
const jobId = parseInt(document.getElementById('modalJobId').value);
|
||||||
const printer = parseInt(document.getElementById('modalPrinter').value);
|
const printer = parseInt(document.getElementById('modalPrinter').value);
|
||||||
const start = document.getElementById('modalStart').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',
|
method: 'POST',
|
||||||
headers: {'Content-Type':'application/json'},
|
headers: {'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start})
|
body: JSON.stringify({job_id: jobId, printer_id: printer, planned_start: start,
|
||||||
}).then(r => r.json());
|
already_done: alreadyDone})
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('scheduleModal')).hide();
|
||||||
location.reload();
|
location.reload();
|
||||||
} else {
|
} 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;
|
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) {
|
function openSlotModal(slot) {
|
||||||
currentSlot = slot;
|
currentSlot = slot;
|
||||||
document.getElementById('slotModalId').value = slot.id;
|
document.getElementById('slotModalId').value = slot.id;
|
||||||
@@ -350,17 +628,55 @@ function openSlotModal(slot) {
|
|||||||
`${slot.job_name} · ${slot.printer_name} · ${formatDt(slot.planned_start)}`;
|
`${slot.job_name} · ${slot.printer_name} · ${formatDt(slot.planned_start)}`;
|
||||||
document.getElementById('slotModalStatus').value = slot.status || 'planned';
|
document.getElementById('slotModalStatus').value = slot.status || 'planned';
|
||||||
document.getElementById('slotModalNote').value = slot.failure_note || '';
|
document.getElementById('slotModalNote').value = slot.failure_note || '';
|
||||||
|
document.getElementById('slotModalWasted').value = '';
|
||||||
|
document.getElementById('slotModalActual').value = '';
|
||||||
|
onStatusChange();
|
||||||
new bootstrap.Modal(document.getElementById('slotModal')).show();
|
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() {
|
async function updateSlotStatus() {
|
||||||
const id = document.getElementById('slotModalId').value;
|
const id = document.getElementById('slotModalId').value;
|
||||||
const status = document.getElementById('slotModalStatus').value;
|
const status = document.getElementById('slotModalStatus').value;
|
||||||
const note = document.getElementById('slotModalNote').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`, {
|
await fetch(`/api/slots/${id}/status`, {
|
||||||
method:'POST', headers:{'Content-Type':'application/json'},
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
body: JSON.stringify({status, note})
|
body: JSON.stringify({status, note})
|
||||||
});
|
});
|
||||||
|
}
|
||||||
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('slotModal')).hide();
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
@@ -374,5 +690,71 @@ async function deleteSlot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initTimeline();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% 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">
|
<div class="card-body p-0">
|
||||||
<table class="table table-hover mb-0">
|
<table class="table table-hover mb-0">
|
||||||
<thead class="table-light">
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for p in printers %}
|
{% for p in printers %}
|
||||||
@@ -28,9 +28,20 @@
|
|||||||
<span class="badge bg-secondary">Inactive</span>
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 class="text-muted small">{{ p.notes or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-1">
|
<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) }}">
|
<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">
|
<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 %}
|
{% 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>
|
<label class="form-label fw-semibold">Nom</label>
|
||||||
<input type="text" name="name" class="form-control" required placeholder="ex: A1 #7, X1C #1">
|
<input type="text" name="name" class="form-control" required placeholder="ex: A1 #7, X1C #1">
|
||||||
</div>
|
</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">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Notes</label>
|
<label class="form-label fw-semibold">Notes</label>
|
||||||
<input type="text" name="notes" class="form-control" placeholder="couleur, emplacement…">
|
<input type="text" name="notes" class="form-control" placeholder="couleur, emplacement…">
|
||||||
@@ -80,4 +97,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% 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>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="mt-4">
|
<!-- Home Assistant -->
|
||||||
<button type="submit" class="btn px-4 py-2 fw-bold" style="background:#ff6b35;color:#fff">
|
<div class="col-12">
|
||||||
<i class="bi bi-save me-2"></i>Enregistrer les parametres
|
<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>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% 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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user