Files
grotti/web/templates/statistik.html
T

315 lines
12 KiB
HTML

{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Statistik</h1>
<p class="tiny">Auslastung, Verbrauch und Erfolgsquote aus dem Protokoll.</p>
</div>
<div class="page-head-side">
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
</div>
</div>
<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>
<button type="button" data-period="week" class="fchip">Woche</button>
<button type="button" data-period="month" class="fchip">Monat</button>
<button type="button" data-period="yesterday" class="fchip">Gestern</button>
<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>
<details class="stat-more">
<summary>Weitere Filter</summary>
<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>
<select id="project"><option value="">Alle Projekte</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>
</details>
</div>
<p id="stat-note"></p>
<div id="stat" class="statistik"></div>
{% endblock %}
{% block script %}
<script>
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) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
function startOfWeek(date) {
const day = date.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate() + mondayOffset);
return start;
}
function periodRange(key) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (key === "all") return { since: "", until: "" };
if (key === "day") return { since: isoDate(today), until: isoDate(today) };
if (key === "yesterday") {
const day = new Date(today);
day.setDate(day.getDate() - 1);
return { since: isoDate(day), until: isoDate(day) };
}
if (key === "week") {
const start = startOfWeek(today);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "last_week") {
const start = startOfWeek(today);
start.setDate(start.getDate() - 7);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "month") {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return { since: isoDate(start), until: isoDate(end) };
}
if (key === "last_month") {
const start = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const end = new Date(today.getFullYear(), today.getMonth(), 0);
return { since: isoDate(start), until: isoDate(end) };
}
return { since: "", until: "" };
}
function setPeriodActive(key) {
document.querySelectorAll("#period [data-period]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.period === key);
});
}
function clearPeriodActive() {
document.querySelectorAll("#period [data-period]").forEach((btn) => btn.classList.remove("on"));
}
function fillPrinters(rows) {
const select = document.getElementById("host");
const current = select.value;
const known = new Set([...select.options].map((o) => o.value));
(rows || []).forEach((row) => {
if (!row.printer_host || known.has(row.printer_host)) return;
const opt = document.createElement("option");
opt.value = row.printer_host;
opt.textContent = row.printer_name || row.printer_host;
select.appendChild(opt);
known.add(row.printer_host);
});
if ([...select.options].some((o) => o.value === current)) select.value = current;
}
function barChart(items, valueKey, labelFn) {
const rows = (items || []).filter((item) => Number(item[valueKey]) > 0);
if (!rows.length) return '<p class="empty">Keine Werte fürs Diagramm.</p>';
const max = Math.max(...rows.map((item) => Number(item[valueKey]) || 0)) || 1;
return `<div class="bar-chart" role="img">${rows.map((item) => {
const value = Number(item[valueKey]) || 0;
const pct = Math.max(4, Math.round(100 * value / max));
return `<div class="bar-row">
<span class="bar-label">${escapeHtml(labelFn(item))}</span>
<span class="bar-track"><span class="bar-fill" style="width:${pct}%"></span></span>
<span class="bar-val">${escapeHtml(String(item._barLabel || value))}</span>
</div>`;
}).join("")}</div>`;
}
function render(data) {
fillPrinters(data.by_printer);
const o = data.overview || {};
const jobs = o.jobs || 0;
const success = jobs ? Math.round(100 * (o.completed || 0) / jobs) : 0;
const scrap = jobs ? Math.round(100 * (o.scrap || 0) / jobs) : 0;
const ratio = data.estimate_ratio != null
? (data.estimate_ratio * 100).toFixed(0) + " %"
: "—";
if (!jobs) {
document.getElementById("stat").innerHTML = `
<div class="empty-state">
<svg class="ico"><use href="#i-grid"/></svg>
<p>Keine Läufe im gewählten Zeitraum.</p>
<a class="ghost" href="/protokoll">Protokoll →</a>
</div>`;
return;
}
const printers = (data.by_printer || []).map((p) => `
<tr>
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
<td class="num">${p.jobs}</td>
<td class="num">${hours(p.duration_s)}</td>
<td class="num">${grams(p.weight_g)}</td>
<td class="num">${euro(p.cost)}</td>
<td class="num">${p.completed}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
const reasons = (data.scrap_reasons || []).map((r) => `
<tr>
<td>${escapeHtml(r.reason || "—")}</td>
<td class="num">${r.count}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="2">Kein Ausschuss im Zeitraum.</td></tr>';
const materials = (data.by_material || []).map((m) => `
<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="6">Kein Spulenabzug im Zeitraum.</td></tr>';
const chartPrinters = (data.by_printer || []).map((p) => ({
...p,
_barLabel: hours(p.duration_s),
}));
const chartMaterials = (data.by_material || []).map((m) => ({
...m,
_barLabel: grams(m.weight_g),
}));
document.getElementById("stat").innerHTML = `
<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")}
</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">
<h2>Druckzeit je Drucker</h2>
${barChart(chartPrinters, "duration_s", (p) => p.printer_name || p.printer_host || "—")}
</div>
<div class="card">
<h2>Verbrauch je Material</h2>
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand, m.color].filter(Boolean).join(" · ") || "—")}
</div>
</div>
<div class="card">
<h2>Nach Drucker</h2>
<div class="table-wrap"><table>
<thead><tr><th>Drucker</th><th>Läufe</th><th>Zeit</th><th>Gramm</th><th>Kosten</th><th>Fertig</th></tr></thead>
<tbody>${printers}</tbody>
</table></div>
</div>
<div class="card">
<h2>Ausschuss-Gründe</h2>
<div class="table-wrap"><table>
<thead><tr><th>Grund</th><th>Anzahl</th></tr></thead>
<tbody>${reasons}</tbody>
</table></div>
</div>
<div class="card">
<h2>Nach Material</h2>
<div class="table-wrap"><table>
<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>`;
}
async function fillProjects() {
const select = document.getElementById("project");
if (!select || select.dataset.filled) return;
try {
const rows = await api("/projekte?status=");
const current = select.value;
rows.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
select.appendChild(opt);
});
select.dataset.filled = "1";
if ([...select.options].some((o) => o.value === current)) select.value = current;
} catch (error) { /* optional */ }
}
async function load() {
await fillProjects();
const params = new URLSearchParams();
const since = document.getElementById("since").value;
const until = document.getElementById("until").value;
const host = document.getElementById("host").value;
const project = document.getElementById("project").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 (project) params.set("project_id", project);
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;
document.getElementById("until").value = range.until;
setPeriodActive(key);
load().catch((error) => showNote("stat-note", error));
}
document.getElementById("period").addEventListener("click", (event) => {
const button = event.target.closest("[data-period]");
if (!button) return;
applyPeriod(button.dataset.period);
});
["since", "until"].forEach((id) => {
document.getElementById(id).addEventListener("change", () => {
clearPeriodActive();
scheduleLoad();
});
});
document.getElementById("host").addEventListener("change", scheduleLoad);
document.getElementById("project").addEventListener("change", scheduleLoad);
["material", "brand", "color"].forEach((id) => {
const node = document.getElementById(id);
if (!node) return;
node.addEventListener("input", scheduleLoad);
node.addEventListener("change", scheduleLoad);
});
load().catch((error) => showNote("stat-note", error));
</script>
{% endblock %}