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
Deploy via Portainer / deploy (push) Successful in 0s
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -81,4 +81,107 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Gaspillage & Échecs ──────────────────────────────────────────────────── -->
|
||||
<div class="row g-4 mt-0">
|
||||
|
||||
<!-- Gaspillage par matière -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-trash3 text-danger me-2"></i>Filament gaspillé par matière
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if waste_by_material %}
|
||||
{% set max_waste = waste_by_material | map(attribute='wasted_g') | max %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Matière</th>
|
||||
<th class="text-end">Gaspillé</th>
|
||||
<th class="text-end text-muted small">Stock restant</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in waste_by_material %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if m.color %}
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;
|
||||
background:{{ m.color | css_color }};border:1px solid #ccc;
|
||||
vertical-align:middle;margin-right:5px"></span>
|
||||
{% endif %}
|
||||
<span class="fw-semibold">{{ m.name }}</span>
|
||||
{% if m.type %}<span class="badge bg-light text-dark ms-1 small">{{ m.type }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span class="text-danger fw-bold">{{ m.wasted_g }} g</span>
|
||||
{% if max_waste > 0 %}
|
||||
<div class="progress mt-1" style="height:3px">
|
||||
<div class="progress-bar bg-danger" style="width:{{ (m.wasted_g / max_waste * 100)|round(0) }}%"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-muted small">{{ m.stock_g }} g</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-check-circle text-success fs-2 d-block mb-2"></i>
|
||||
Aucun gaspillage enregistré.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Taux d'échec par imprimante -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<i class="bi bi-printer text-warning me-2"></i>Taux d'échec par imprimante
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if failure_by_printer %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Imprimante</th>
|
||||
<th class="text-center">Taux</th>
|
||||
<th class="text-end">Gaspillé</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in failure_by_printer %}
|
||||
{% set rate = (p.failed_slots / p.total_slots * 100) | round(1) %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ p.name }}</div>
|
||||
<div class="text-muted small">{{ p.failed_slots }}/{{ p.total_slots }} créneaux</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge {% if rate >= 20 %}bg-danger{% elif rate >= 10 %}bg-warning text-dark{% else %}bg-success{% endif %}">
|
||||
{{ rate }}%
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end {% if p.wasted_g > 0 %}text-danger{% else %}text-muted{% endif %} fw-semibold">
|
||||
{{ p.wasted_g }} g
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-check-circle text-success fs-2 d-block mb-2"></i>
|
||||
Aucun échec enregistré.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user