Compare commits

...

32 Commits

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