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

This commit is contained in:
Jo
2026-07-07 09:16:24 +02:00
parent 7b65c4b2e2
commit b22e5e0fb4
9 changed files with 502 additions and 78 deletions
+51 -5
View File
@@ -219,6 +219,7 @@ def init_db():
('nc_username',''),
('nc_app_password',''),
('nc_root_path','/3D'),
('ical_token',''),
]
conn.executemany('INSERT OR IGNORE INTO settings VALUES (?,?)', defaults)
@@ -1364,11 +1365,36 @@ def mobile_quick():
except Exception:
pass
elif action == 'fail':
slot_id = request.form.get('slot_id')
note = request.form.get('failure_note', '')
slot_id = request.form.get('slot_id')
note = request.form.get('failure_note', '')
wasted_g = float(request.form.get('wasted_g', 0) or 0)
if slot_id:
conn.execute("UPDATE print_slots SET status='failed',failure_note=? WHERE id=?",
(note, slot_id))
slot = conn.execute('''
SELECT ps.weight_g as plate_g, j.material_id, j.weight_g
FROM print_slots ps JOIN jobs j ON ps.job_id=j.id
WHERE ps.id=?''', (slot_id,)).fetchone()
if slot:
if wasted_g == 0:
wasted_g = slot['weight_g'] or 0
conn.execute(
"UPDATE print_slots SET status='failed', failure_note=?, wasted_g=? WHERE id=?",
(note, wasted_g, slot_id))
if slot['material_id'] and wasted_g > 0:
conn.execute(
'UPDATE materials SET stock_g=MAX(0,stock_g-?), total_wasted_g=COALESCE(total_wasted_g,0)+? WHERE id=?',
(wasted_g, wasted_g, slot['material_id']))
conn.commit()
elif action == 'done':
slot_id = request.form.get('slot_id')
actual_g = float(request.form.get('actual_g', 0) or 0)
if slot_id:
slot = conn.execute(
'SELECT j.material_id FROM print_slots ps JOIN jobs j ON ps.job_id=j.id WHERE ps.id=?',
(slot_id,)).fetchone()
conn.execute("UPDATE print_slots SET status='done' WHERE id=?", (slot_id,))
if slot and slot['material_id'] and actual_g > 0:
conn.execute('UPDATE materials SET stock_g=MAX(0,stock_g-?) WHERE id=?',
(actual_g, slot['material_id']))
conn.commit()
conn.close()
return redirect(url_for('mobile_quick'))
@@ -1975,8 +2001,28 @@ def stats():
ROUND(SUM(j.weight_g),1) as total_g, ROUND(SUM(j.final_price),2) as revenue
FROM jobs j LEFT JOIN materials m ON j.material_id=m.id
GROUP BY j.material_id ORDER BY total_g DESC''').fetchall()
waste_by_material = conn.execute('''
SELECT COALESCE(m.name,'Non défini') as name, m.type,
COALESCE(m.color,'') as color,
ROUND(COALESCE(m.total_wasted_g,0),1) as wasted_g,
ROUND(COALESCE(m.stock_g,0),1) as stock_g
FROM materials m
WHERE COALESCE(m.total_wasted_g,0) > 0
ORDER BY wasted_g DESC''').fetchall()
failure_by_printer = conn.execute('''
SELECT COALESCE(p.name,'Inconnu') as name,
COUNT(ps.id) as total_slots,
SUM(CASE WHEN ps.status='failed' THEN 1 ELSE 0 END) as failed_slots,
ROUND(SUM(COALESCE(ps.wasted_g,0)),1) as wasted_g
FROM print_slots ps
LEFT JOIN printers p ON ps.printer_id=p.id
GROUP BY ps.printer_id
HAVING total_slots > 0
ORDER BY failed_slots DESC''').fetchall()
conn.close()
return render_template('stats.html', by_client=by_client, by_month=by_month, by_material=by_material)
return render_template('stats.html', by_client=by_client, by_month=by_month,
by_material=by_material, waste_by_material=waste_by_material,
failure_by_printer=failure_by_printer)
# ─── Export CSV ───────────────────────────────────────────────────────────────
+110 -30
View File
@@ -7,35 +7,73 @@
<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;
}
#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;
font-size: 1rem; line-height: 1;
}
#sidebarToggle:hover { color: #fff; background: #2e323d; }
#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; }
@@ -66,51 +104,56 @@
<body>
<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('projects') }}" class="{% if request.endpoint in ['projects','new_project','edit_project','project_detail'] %}active{% endif %}">
<i class="bi bi-folder2-open"></i> Projets
<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 %}">
<i class="bi bi-calendar3"></i> Planning
<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 %}">
<i class="bi bi-phone"></i> Vue mobile
<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 style="padding:.75rem 1.25rem;border-top:1px solid #2e323d;margin-top:auto">
<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 }}
@@ -135,6 +178,43 @@
</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>
{% block scripts %}{% endblock %}
</body>
</html>
+70 -17
View File
@@ -56,12 +56,22 @@
<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'] %}
<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 }}">
<i class="bi bi-box me-1"></i>Voir
</button>
{% 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>
@@ -246,7 +256,12 @@
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
</div>
<div class="modal-body p-0 position-relative" style="height:75vh">
<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…
@@ -332,14 +347,20 @@ function fitCamera() {
controls.update();
}
function loadModel(path, ext) {
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; }
const url = '/api/nextcloud/file?path=' + encodeURIComponent(path);
const mat = new THREE.MeshPhongMaterial({ color: 0xff6b35, specular: 0x333333, shininess: 40, side: THREE.DoubleSide });
const mat = makeMat();
const onError = (e) => {
loader.style.display = 'none';
errDiv.style.display = '';
@@ -355,7 +376,6 @@ function loadModel(path, ext) {
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else if (ext === '3mf') {
new ThreeMFLoader().load(url, obj => {
obj.traverse(child => {
@@ -366,7 +386,6 @@ function loadModel(path, ext) {
loader.style.display = 'none';
fitCamera();
}, undefined, onError);
} else {
loader.style.display = 'none';
errDiv.style.display = '';
@@ -374,19 +393,53 @@ function loadModel(path, ext) {
}
}
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;
title.textContent = path.split('/').pop();
initRenderer();
loadModel(path, ext);
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
+8 -1
View File
@@ -42,7 +42,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 ?')">
+40 -1
View File
@@ -60,6 +60,9 @@
<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>
@@ -176,6 +179,36 @@
</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>
@@ -195,9 +228,15 @@
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Description du problème</label>
<textarea name="failure_note" class="form-control" rows="3"
<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>
+41 -12
View File
@@ -9,21 +9,28 @@
<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-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>
<div class="form-text mt-1">Votre fichier .3mf depuis Bambu Studio pour remplir poids et durée automatiquement.</div>
</div>
<div class="form-text mt-1">Votre fichier .3mf depuis Bambu Studio pour remplir poids et duree automatiquement.</div>
</div>
</div>
@@ -320,6 +327,28 @@
{% block scripts %}
<script>
// ── Section Bambu collapsible ────────────────────────────────────────────────
(function () {
const KEY = 'bambu_open';
const body = document.getElementById('bambuBody');
const chevron = document.getElementById('bambuChevron');
const isOpen = localStorage.getItem(KEY) !== 'false'; // open 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 = [];
+72 -6
View File
@@ -148,9 +148,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 +170,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()">
@@ -343,6 +364,13 @@ async function saveSlot() {
}
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 +378,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();
}
+7 -6
View File
@@ -194,8 +194,8 @@
</div>
</div>
</div>
<!-- Nextcloud -->
<div class="col-12">
<div class="card">
<div class="card-header py-3"><i class="bi bi-cloud me-2"></i>Nextcloud — Intégration fichiers 3D</div>
@@ -271,12 +271,13 @@
</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 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>
</div>
</form>
{% 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 %}