Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.

This commit is contained in:
Dominic Gundel committed 2026-10-18 20:10:32 +02:00
1 parent 4ddffbbeb1
commit 67a89d1794
45 files changed
+2856 -320

No files matched your search

+37 -32
View File
@@ -9,7 +9,7 @@
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div>
</div>
<div class="toolbar page-tools">
<div class="toolbar page-tools stat-tools">
<div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
<button type="button" data-period="all" class="fchip on">Alles</button>
<button type="button" data-period="day" class="fchip">Tag</button>
@@ -19,20 +19,24 @@
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
</div>
<input type="date" id="since" title="Von" class="date-field">
<input type="date" id="until" title="Bis" class="date-field">
<select id="host"><option value="">Alle Drucker</option></select>
<input type="text" id="material" placeholder="Sorte" size="10">
<input type="text" id="brand" placeholder="Marke" size="10">
<div class="tools-end"><button type="button" id="reload" class="ghost"><svg class="ico"><use href="#i-refresh"/></svg>Aktualisieren</button></div>
<div class="stat-filters">
<input type="date" id="since" title="Von" class="date-field">
<input type="date" id="until" title="Bis" class="date-field">
<select id="host"><option value="">Alle Drucker</option></select>
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
</div>
</div>
<p id="stat-note"></p>
<div id="stat" class="statistik"></div>
{% endblock %}
{% block script %}
<script>
function kpi(label, value, hint, ico) {
return `<div class="kpi"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
function kpi(label, value, hint, ico, extraClass) {
const empty = !value || value === "—" || value === "0 %";
const cls = ["kpi", extraClass || "", empty ? "kpi-empty" : ""].filter(Boolean).join(" ");
return `<div class="${cls}"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
}
function isoDate(date) {
@@ -165,10 +169,11 @@ function render(data) {
<tr>
<td>${escapeHtml(m.material_type || "—")}</td>
<td>${escapeHtml(m.brand || "")}</td>
<td>${escapeHtml(m.color || "")}</td>
<td class="num">${grams(m.weight_g)}</td>
<td class="num">${euro(m.cost)}</td>
<td class="num">${m.jobs}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
</tr>`).join("") || '<tr><td class="empty" colspan="6">Kein Spulenabzug im Zeitraum.</td></tr>';
const chartPrinters = (data.by_printer || []).map((p) => ({
...p,
@@ -180,16 +185,18 @@ function render(data) {
}));
document.getElementById("stat").innerHTML = `
<div class="kpi-row">
<div class="kpi-row kpi-primary">
${kpi("Läufe", String(jobs), "", "layers")}
${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
${kpi("Kosten", euro(o.cost), "", "euro")}
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban")}
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent")}
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge")}
</div>
<div class="kpi-row kpi-secondary">
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban", "kpi-sm")}
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent", "kpi-sm")}
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag", "kpi-sm")}
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge", "kpi-sm")}
</div>
<div class="split">
<div class="card">
@@ -198,7 +205,7 @@ function render(data) {
</div>
<div class="card">
<h2>Verbrauch je Material</h2>
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")}
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand, m.color].filter(Boolean).join(" · ") || "—")}
</div>
</div>
<div class="card">
@@ -218,7 +225,7 @@ function render(data) {
<div class="card">
<h2>Nach Material</h2>
<div class="table-wrap"><table>
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
<thead><tr><th>Sorte</th><th>Marke</th><th>Farbe</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
<tbody>${materials}</tbody>
</table></div>
</div>`;
@@ -231,17 +238,25 @@ async function load() {
const host = document.getElementById("host").value;
const material = document.getElementById("material").value.trim();
const brand = document.getElementById("brand").value.trim();
const color = document.getElementById("color").value.trim();
if (since) params.set("since", since);
if (until) params.set("until", until);
if (host) params.set("host", host);
if (material) params.set("material", material);
if (brand) params.set("brand", brand);
if (color) params.set("color", color);
const q = params.toString();
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
const data = await api("/statistik" + (q ? "?" + q : ""));
render(data);
}
let filterTimer = null;
function scheduleLoad() {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => load().catch((error) => showNote("stat-note", error)), 280);
}
function applyPeriod(key) {
const range = periodRange(key);
document.getElementById("since").value = range.since;
@@ -256,28 +271,18 @@ document.getElementById("period").addEventListener("click", (event) => {
applyPeriod(button.dataset.period);
});
document.getElementById("reload").addEventListener("click", () => {
load().catch((error) => showNote("stat-note", error));
});
["since", "until"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => {
clearPeriodActive();
load().catch((error) => showNote("stat-note", error));
scheduleLoad();
});
});
document.getElementById("host").addEventListener("change", () => {
load().catch((error) => showNote("stat-note", error));
});
["material", "brand"].forEach((id) => {
document.getElementById("host").addEventListener("change", scheduleLoad);
["material", "brand", "color"].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
node.addEventListener("change", () => load().catch((error) => showNote("stat-note", error)));
node.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
load().catch((error) => showNote("stat-note", error));
}
});
node.addEventListener("input", scheduleLoad);
node.addEventListener("change", scheduleLoad);
});
load().catch((error) => showNote("stat-note", error));
</script>