Grotti 3.1.0: Bedarf-UI, Zeitfenster, HT/NT, Theme, Chrome-Fixes.
Bedarf in Sidebar/Material, Drucker-Zeitfenster und Tarif in Einstellungen, helles Theme; Legacy-Token und /api/drucker entfernt; SSE/SW-Fixes gegen hängende Navigation in Chrome.
This commit is contained in:
1 parent
8e88efc094
commit
6e59df22e5
23 files changed
+741
-171
No files matched your search
@@ -203,16 +203,22 @@ function renderList() {
|
||||
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
|
||||
return;
|
||||
}
|
||||
const selected = document.getElementById("order-id").value;
|
||||
orders.forEach((order) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = order.title;
|
||||
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
|
||||
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
||||
tr.addEventListener("click", async () => {
|
||||
const full = await api("/auftraege/" + order.id);
|
||||
fillOrder(full);
|
||||
try {
|
||||
const full = await api("/auftraege/" + order.id);
|
||||
fillOrder(full);
|
||||
renderList();
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
|
||||
@@ -10,25 +10,34 @@
|
||||
<section class="card">
|
||||
<h2>Dieser Browser</h2>
|
||||
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
|
||||
<div class="field" style="max-width:16rem">
|
||||
<label for="display-mode">Anzeige</label>
|
||||
<select id="display-mode">
|
||||
<option value="desktop">Desktop</option>
|
||||
<option value="shelf">Regal (Touch)</option>
|
||||
</select>
|
||||
<div class="grid-2 narrow-fields">
|
||||
<div class="field">
|
||||
<label for="display-mode">Anzeige</label>
|
||||
<select id="display-mode">
|
||||
<option value="desktop">Desktop</option>
|
||||
<option value="shelf">Regal (Touch)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="theme-mode">Darstellung</label>
|
||||
<select id="theme-mode">
|
||||
<option value="dark">Dunkel</option>
|
||||
<option value="light">Hell</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tz-mode">Zeitzone</label>
|
||||
<select id="tz-mode">
|
||||
<option value="auto">Browser (automatisch)</option>
|
||||
<option value="Europe/Berlin">Europe/Berlin</option>
|
||||
<option value="Europe/Vienna">Europe/Vienna</option>
|
||||
<option value="Europe/Zurich">Europe/Zurich</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
|
||||
<div class="field" style="max-width:16rem; margin-top:0.85rem">
|
||||
<label for="tz-mode">Zeitzone</label>
|
||||
<select id="tz-mode">
|
||||
<option value="auto">Browser (automatisch)</option>
|
||||
<option value="Europe/Berlin">Europe/Berlin</option>
|
||||
<option value="Europe/Vienna">Europe/Vienna</option>
|
||||
<option value="Europe/Zurich">Europe/Zurich</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.</p>
|
||||
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung und Zeitzone nur dieser Browser.</p>
|
||||
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
|
||||
</section>
|
||||
|
||||
@@ -68,14 +77,9 @@
|
||||
|
||||
<section class="card admin-only">
|
||||
<h2>Drucker</h2>
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
|
||||
<tbody id="printer-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="printer-add" class="grid-2" style="margin-top:1rem">
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).</p>
|
||||
<div id="printer-list" class="printer-edit-list"></div>
|
||||
<form id="printer-add" class="grid-2 section-gap">
|
||||
<div class="field">
|
||||
<label for="add-name">Name</label>
|
||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
||||
@@ -143,8 +147,11 @@
|
||||
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar.</p>
|
||||
<div class="actions"><button type="button" id="ntfy-save">Speichern</button></div>
|
||||
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="ntfy-save">Speichern</button>
|
||||
<button type="button" class="ghost" id="ntfy-test">Test senden</button>
|
||||
</div>
|
||||
<p id="ntfy-note"></p>
|
||||
</section>
|
||||
|
||||
@@ -171,7 +178,15 @@
|
||||
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
|
||||
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
|
||||
</div>
|
||||
<p class="hint">Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.</p>
|
||||
<h3 class="section-gap">HT / NT-Tarif</h3>
|
||||
<label class="check"><input type="checkbox" id="en-tariff"> Unterschiedliche Preise für Tag und Nacht</label>
|
||||
<div class="grid-2" id="en-tariff-fields" hidden>
|
||||
<div class="field"><label for="en-ht">HT € / kWh</label><input id="en-ht" type="number" step="0.01"></div>
|
||||
<div class="field"><label for="en-nt">NT € / kWh</label><input id="en-nt" type="number" step="0.01"></div>
|
||||
<div class="field"><label for="en-nt-from">NT ab</label><input id="en-nt-from" type="time" value="22:00"></div>
|
||||
<div class="field"><label for="en-nt-to">NT bis</label><input id="en-nt-to" type="time" value="06:00"></div>
|
||||
</div>
|
||||
<p class="hint">Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.</p>
|
||||
<p id="en-ed-hint" class="hint" hidden></p>
|
||||
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
||||
<p id="en-note"></p>
|
||||
@@ -217,7 +232,7 @@
|
||||
</form>
|
||||
<p id="token-note"></p>
|
||||
<p class="ok" id="token-once" hidden></p>
|
||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
|
||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
@@ -234,39 +249,87 @@ if (!isAdmin) {
|
||||
|
||||
/* showNote aus api.js: Inline-Hinweis + Toast */
|
||||
|
||||
function windowFrom(row) {
|
||||
const sw = row.start_window || {};
|
||||
const allowed = Array.isArray(sw.allowed) ? sw.allowed : [];
|
||||
const first = allowed[0] || {};
|
||||
return {
|
||||
enabled: allowed.length > 0,
|
||||
from: first.from || "22:00",
|
||||
to: first.to || "06:00",
|
||||
maxHours: sw.max_hours_overnight || "",
|
||||
};
|
||||
}
|
||||
|
||||
function renderPrinters(rows) {
|
||||
const body = document.getElementById("printer-rows");
|
||||
body.innerHTML = "";
|
||||
const root = document.getElementById("printer-list");
|
||||
root.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
|
||||
root.innerHTML = '<div class="empty-state"><svg class="ico"><use href="#i-printer"/></svg><p>Keine Drucker.</p></div>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="">—</option>
|
||||
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
|
||||
tr.querySelector('[data-f="name"]').value = row.name || "";
|
||||
tr.querySelector('[data-f="host"]').value = row.host || "";
|
||||
tr.querySelector('[data-f="family"]').value = row.family || "";
|
||||
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
|
||||
const win = windowFrom(row);
|
||||
const card = document.createElement("div");
|
||||
card.className = "printer-edit";
|
||||
card.innerHTML = `
|
||||
<div class="grid-2">
|
||||
<div class="field"><label>Name</label><input data-f="name"></div>
|
||||
<div class="field"><label>Host / IP</label><input data-f="host"></div>
|
||||
<div class="field"><label>Familie</label><select data-f="family">
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="">—</option>
|
||||
</select></div>
|
||||
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" data-f="locked"> Gesperrt</label></div>
|
||||
</div>
|
||||
<details class="mat-more" ${win.enabled ? "open" : ""}>
|
||||
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Start-Zeitfenster</span>
|
||||
<span class="tiny">${win.enabled ? escapeHtml(win.from) + "–" + escapeHtml(win.to) : "aus"}</span>
|
||||
</summary>
|
||||
<div class="grid-2" style="margin-top:0.6rem">
|
||||
<label class="check" style="grid-column:1/-1"><input type="checkbox" data-f="win-on"> Nur in Zeitfenster starten</label>
|
||||
<div class="field"><label>Von</label><input type="time" data-f="win-from"></div>
|
||||
<div class="field"><label>Bis</label><input type="time" data-f="win-to"></div>
|
||||
<div class="field"><label>Max. Stunden (Nachtjob)</label><input type="number" min="0" step="0.5" data-f="win-max" placeholder="leer = egal"></div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="actions"></div>`;
|
||||
card.querySelector('[data-f="name"]').value = row.name || "";
|
||||
card.querySelector('[data-f="host"]').value = row.host || "";
|
||||
card.querySelector('[data-f="family"]').value = row.family || "";
|
||||
card.querySelector('[data-f="locked"]').checked = !!row.locked;
|
||||
card.querySelector('[data-f="win-on"]').checked = win.enabled;
|
||||
card.querySelector('[data-f="win-from"]').value = win.from;
|
||||
card.querySelector('[data-f="win-to"]').value = win.to;
|
||||
card.querySelector('[data-f="win-max"]').value = win.maxHours;
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = "Speichern";
|
||||
save.addEventListener("click", async () => {
|
||||
const on = card.querySelector('[data-f="win-on"]').checked;
|
||||
const start_window = {};
|
||||
if (on) {
|
||||
start_window.allowed = [{
|
||||
from: card.querySelector('[data-f="win-from"]').value || "22:00",
|
||||
to: card.querySelector('[data-f="win-to"]').value || "06:00",
|
||||
}];
|
||||
const maxH = Number(card.querySelector('[data-f="win-max"]').value);
|
||||
if (maxH > 0) start_window.max_hours_overnight = maxH;
|
||||
}
|
||||
try {
|
||||
await api("/drucker/id/" + row.id, {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
name: tr.querySelector('[data-f="name"]').value,
|
||||
host: tr.querySelector('[data-f="host"]').value,
|
||||
family: tr.querySelector('[data-f="family"]').value,
|
||||
locked: tr.querySelector('[data-f="locked"]').checked,
|
||||
name: card.querySelector('[data-f="name"]').value,
|
||||
host: card.querySelector('[data-f="host"]').value,
|
||||
family: card.querySelector('[data-f="family"]').value,
|
||||
locked: card.querySelector('[data-f="locked"]').checked,
|
||||
start_window,
|
||||
},
|
||||
});
|
||||
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
|
||||
showNote("printer-note", null, "Gespeichert: " + card.querySelector('[data-f="name"]').value);
|
||||
toast("Drucker gespeichert.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("printer-note", error);
|
||||
@@ -287,9 +350,8 @@ function renderPrinters(rows) {
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[4].appendChild(save);
|
||||
tr.children[4].appendChild(del);
|
||||
body.appendChild(tr);
|
||||
card.querySelector(".actions").append(save, del);
|
||||
root.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,6 +492,13 @@ async function load() {
|
||||
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
||||
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
||||
document.getElementById("en-util").value = en.utilization ?? 50;
|
||||
const tariff = en.tariff || null;
|
||||
document.getElementById("en-tariff").checked = !!tariff;
|
||||
document.getElementById("en-tariff-fields").hidden = !tariff;
|
||||
document.getElementById("en-ht").value = (tariff && tariff.ht_euro) ?? (en.euro_per_kwh ?? 0.35);
|
||||
document.getElementById("en-nt").value = (tariff && tariff.nt_euro) ?? (en.euro_per_kwh ?? 0.35);
|
||||
document.getElementById("en-nt-from").value = (tariff && tariff.nt_from) || "22:00";
|
||||
document.getElementById("en-nt-to").value = (tariff && tariff.nt_to) || "06:00";
|
||||
updateEnergyEdHint();
|
||||
}
|
||||
const legacy = document.getElementById("legacy-token-hint");
|
||||
@@ -634,6 +703,19 @@ document.getElementById("tg-test").addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("ntfy-test")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/ntfy/test", { method: "POST", json: {} });
|
||||
showNote("ntfy-note", null, "Testnachricht gesendet.");
|
||||
} catch (error) {
|
||||
showNote("ntfy-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("en-tariff")?.addEventListener("change", (event) => {
|
||||
document.getElementById("en-tariff-fields").hidden = !event.target.checked;
|
||||
});
|
||||
|
||||
function updateEnergyEdHint() {
|
||||
const hint = document.getElementById("en-ed-hint");
|
||||
if (!hint) return;
|
||||
@@ -657,21 +739,25 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
try {
|
||||
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
|
||||
const bed = Number(document.getElementById("en-bed").value) || 0;
|
||||
await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
energy: {
|
||||
watts: Number(document.getElementById("en-watts").value),
|
||||
nozzle_watts: nozzle,
|
||||
bed_watts: bed,
|
||||
euro_per_kwh: Number(document.getElementById("en-kwh").value),
|
||||
dryer_watts: Number(document.getElementById("en-dryer").value),
|
||||
dry_hours: Number(document.getElementById("en-dryh").value),
|
||||
waste_percent: Number(document.getElementById("en-waste").value),
|
||||
utilization: Number(document.getElementById("en-util").value),
|
||||
},
|
||||
},
|
||||
});
|
||||
const energy = {
|
||||
watts: Number(document.getElementById("en-watts").value),
|
||||
nozzle_watts: nozzle,
|
||||
bed_watts: bed,
|
||||
euro_per_kwh: Number(document.getElementById("en-kwh").value),
|
||||
dryer_watts: Number(document.getElementById("en-dryer").value),
|
||||
dry_hours: Number(document.getElementById("en-dryh").value),
|
||||
waste_percent: Number(document.getElementById("en-waste").value),
|
||||
utilization: Number(document.getElementById("en-util").value),
|
||||
};
|
||||
if (document.getElementById("en-tariff").checked) {
|
||||
energy.tariff = {
|
||||
ht_euro: Number(document.getElementById("en-ht").value),
|
||||
nt_euro: Number(document.getElementById("en-nt").value),
|
||||
nt_from: document.getElementById("en-nt-from").value || "22:00",
|
||||
nt_to: document.getElementById("en-nt-to").value || "06:00",
|
||||
};
|
||||
}
|
||||
await api("/einstellungen", { method: "PATCH", json: { energy } });
|
||||
updateEnergyEdHint();
|
||||
if (nozzle <= 0 || bed <= 0) {
|
||||
const note = document.getElementById("en-note");
|
||||
@@ -688,6 +774,13 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function flashDisplayNote() {
|
||||
const note = document.getElementById("display-note");
|
||||
if (!note) return;
|
||||
note.hidden = false;
|
||||
setTimeout(() => { note.hidden = true; }, 2500);
|
||||
}
|
||||
|
||||
(function initDisplayMode() {
|
||||
const sel = document.getElementById("display-mode");
|
||||
if (!sel) return;
|
||||
@@ -703,11 +796,28 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
||||
document.body.classList.toggle("shelf", sel.value === "shelf");
|
||||
}
|
||||
const note = document.getElementById("display-note");
|
||||
if (note) {
|
||||
note.hidden = false;
|
||||
setTimeout(() => { note.hidden = true; }, 2500);
|
||||
flashDisplayNote();
|
||||
});
|
||||
})();
|
||||
|
||||
(function initThemeMode() {
|
||||
const sel = document.getElementById("theme-mode");
|
||||
if (!sel) return;
|
||||
try {
|
||||
const value = localStorage.getItem("grotti-theme") || "dark";
|
||||
sel.value = ["dark", "light", "system"].includes(value) ? value : "dark";
|
||||
} catch (error) {
|
||||
sel.value = "dark";
|
||||
}
|
||||
sel.addEventListener("change", () => {
|
||||
try { localStorage.setItem("grotti-theme", sel.value); } catch (error) {}
|
||||
if (typeof applyTheme === "function") applyTheme(sel.value);
|
||||
else {
|
||||
const light = sel.value === "light" || (sel.value === "system" && window.matchMedia("(prefers-color-scheme: light)").matches);
|
||||
document.documentElement.dataset.theme = light ? "light" : "dark";
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
}
|
||||
flashDisplayNote();
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
+111
-16
@@ -9,11 +9,27 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=54">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=56">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
(function () {
|
||||
function applyThemeEarly(mode) {
|
||||
var light = mode === "light";
|
||||
if (mode === "system") {
|
||||
try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) { light = false; }
|
||||
}
|
||||
document.documentElement.dataset.theme = light ? "light" : "dark";
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
var meta = document.getElementById("theme-color-meta");
|
||||
if (meta) meta.content = light ? "#f4f4f6" : "#0a0a0c";
|
||||
}
|
||||
window.applyTheme = function (mode) {
|
||||
try { if (mode) localStorage.setItem("grotti-theme", mode); } catch (e) {}
|
||||
var m = mode;
|
||||
try { m = m || localStorage.getItem("grotti-theme") || "dark"; } catch (e) { m = "dark"; }
|
||||
applyThemeEarly(m);
|
||||
};
|
||||
try {
|
||||
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
||||
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
|
||||
@@ -35,6 +51,14 @@
|
||||
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
||||
document.documentElement.classList.add("nav-collapsed");
|
||||
}
|
||||
applyThemeEarly(localStorage.getItem("grotti-theme") || "dark");
|
||||
} catch (e) {}
|
||||
try {
|
||||
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () {
|
||||
var mode = "dark";
|
||||
try { mode = localStorage.getItem("grotti-theme") || "dark"; } catch (err) {}
|
||||
if (mode === "system") applyThemeEarly("system");
|
||||
});
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
@@ -132,6 +156,11 @@
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</details>
|
||||
<details class="queue-box" data-box="bedarf" id="bedarf-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bedarf</span><span class="count bad" id="bedarf-count">0</span></summary>
|
||||
<ul class="queue-list" id="bedarf-list"></ul>
|
||||
<div class="queue-foot"><a href="/material#bedarf">Material →</a></div>
|
||||
</details>
|
||||
</div>
|
||||
<nav class="nav-secondary" aria-label="Weitere Seiten">
|
||||
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
|
||||
@@ -171,10 +200,10 @@
|
||||
<a href="/einstellungen">Einstellungen</a>
|
||||
<a href="/doku">Doku</a>
|
||||
</nav>
|
||||
<form method="dialog"><button class="ghost">Schließen</button></form>
|
||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script>
|
||||
<script>
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||
@@ -477,6 +506,25 @@ async function refreshEvents() {
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
async function refreshBedarf() {
|
||||
let data;
|
||||
try { data = await api("/bedarf"); } catch (error) { return; }
|
||||
const shortfall = data.shortfall || [];
|
||||
const box = document.getElementById("bedarf-box");
|
||||
if (!box) return;
|
||||
box.hidden = !shortfall.length;
|
||||
document.getElementById("bedarf-count").textContent = shortfall.length;
|
||||
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => {
|
||||
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament";
|
||||
return `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-sub">${grams(item.shortfall_g)} fehlen</span></div>
|
||||
<span class="queue-msg">${grams(item.weight_g)} Queue · ${grams(item.remaining_g)} Rest</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data);
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
let items;
|
||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||
@@ -508,11 +556,29 @@ async function refreshTasks() {
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById("mobile-more")?.addEventListener("click", () => {
|
||||
document.getElementById("mobile-more-sheet")?.showModal();
|
||||
});
|
||||
(function mobileMoreSheet() {
|
||||
const sheet = document.getElementById("mobile-more-sheet");
|
||||
const btn = document.getElementById("mobile-more");
|
||||
if (!sheet || !btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
if (typeof sheet.showModal === "function") sheet.showModal();
|
||||
});
|
||||
// Modal schließt und blockiert sonst die ganze App (Chrome).
|
||||
sheet.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[href]");
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
const href = link.getAttribute("href");
|
||||
sheet.close();
|
||||
if (href) location.href = href;
|
||||
});
|
||||
sheet.addEventListener("close", () => {
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
btn.addEventListener("click", () => btn.setAttribute("aria-expanded", "true"));
|
||||
})();
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/static/sw.js").catch(() => {});
|
||||
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
|
||||
}
|
||||
|
||||
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||
@@ -524,15 +590,21 @@ document.getElementById("events-clear").addEventListener("click", async (event)
|
||||
|
||||
window.refreshPrintQueue = refreshPrintQueue;
|
||||
window.refreshSendQueue = refreshSendQueue;
|
||||
window.refreshBedarf = refreshBedarf;
|
||||
refreshSendQueue();
|
||||
refreshPrintQueue();
|
||||
refreshEvents();
|
||||
refreshTasks();
|
||||
refreshBedarf();
|
||||
window.refreshTasks = refreshTasks;
|
||||
|
||||
(function liveUpdates() {
|
||||
let streamLive = false;
|
||||
const sseKinds = {
|
||||
queue: () => refreshPrintQueue(),
|
||||
queue: () => {
|
||||
refreshPrintQueue();
|
||||
refreshBedarf();
|
||||
},
|
||||
senden: () => refreshSendQueue(),
|
||||
ereignis: () => refreshEvents(),
|
||||
aufgaben: () => refreshTasks(),
|
||||
@@ -543,23 +615,37 @@ window.refreshTasks = refreshTasks;
|
||||
},
|
||||
};
|
||||
|
||||
function whenStreamDown(fn) {
|
||||
return async () => {
|
||||
if (streamLive) return;
|
||||
await fn();
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof EventSource !== "undefined") {
|
||||
let es;
|
||||
let retryMs = 2000;
|
||||
const retryMax = 60000;
|
||||
let hadStream = false;
|
||||
let retryTimer = 0;
|
||||
|
||||
function connectStream() {
|
||||
if (es) {
|
||||
try { es.close(); } catch (e) {}
|
||||
}
|
||||
es = new EventSource("/api/v1/stream", { withCredentials: true });
|
||||
es.addEventListener("open", () => {
|
||||
streamLive = true;
|
||||
retryMs = 2000;
|
||||
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
|
||||
hadStream = true;
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
streamLive = false;
|
||||
try { es.close(); } catch (e) {}
|
||||
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
|
||||
setTimeout(connectStream, retryMs);
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connectStream, retryMs);
|
||||
retryMs = Math.min(retryMax, retryMs * 2);
|
||||
});
|
||||
Object.keys(sseKinds).forEach((kind) => {
|
||||
@@ -568,12 +654,21 @@ window.refreshTasks = refreshTasks;
|
||||
es.addEventListener("ping", () => {});
|
||||
}
|
||||
connectStream();
|
||||
window.addEventListener("pagehide", () => {
|
||||
streamLive = false;
|
||||
clearTimeout(retryTimer);
|
||||
if (es) {
|
||||
try { es.close(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createPoller(refreshPrintQueue, 8000).start();
|
||||
createPoller(refreshSendQueue, 4000).start();
|
||||
createPoller(refreshEvents, 12000).start();
|
||||
createPoller(refreshTasks, 12000).start();
|
||||
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
|
||||
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
|
||||
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
|
||||
createPoller(whenStreamDown(refreshEvents), 12000).start();
|
||||
createPoller(whenStreamDown(refreshTasks), 12000).start();
|
||||
createPoller(whenStreamDown(refreshBedarf), 30000).start();
|
||||
})();
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card bedarf-card" id="bedarf-card" hidden>
|
||||
<div class="lib-head">
|
||||
<h2 id="bedarf">Bedarf aus Warteschlange</h2>
|
||||
<span class="count" id="bedarf-short-count">0</span>
|
||||
</div>
|
||||
<p class="hint" id="bedarf-hint">Fehlmenge = Queue-Gewicht minus Spulenrest (gleiche Sorte/Farbe).</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Sorte</th><th>Farbe</th><th class="num">Queue</th><th class="num">Rest</th><th class="num">Fehlt</th><th class="num">Jobs</th></tr></thead>
|
||||
<tbody id="bedarf-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tabs mat-tabs" role="tablist">
|
||||
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
|
||||
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
|
||||
@@ -721,17 +735,58 @@ function render() {
|
||||
renderSorten();
|
||||
}
|
||||
|
||||
function renderBedarf(data) {
|
||||
const card = document.getElementById("bedarf-card");
|
||||
const shortfall = (data && data.shortfall) || [];
|
||||
const needed = (data && data.needed) || [];
|
||||
if (!needed.length && !shortfall.length) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.hidden = false;
|
||||
document.getElementById("bedarf-short-count").textContent = shortfall.length
|
||||
? shortfall.length + " Fehl"
|
||||
: "ok";
|
||||
document.getElementById("bedarf-hint").textContent = shortfall.length
|
||||
? shortfall.length + " Sorten reichen nicht für die wartende Queue."
|
||||
: "Queue hat Bedarf — Spulenrest reicht.";
|
||||
const rows = (shortfall.length ? shortfall : needed).map((item) => {
|
||||
const missing = item.shortfall_g != null ? grams(item.shortfall_g) : "—";
|
||||
const rest = item.remaining_g != null
|
||||
? grams(item.remaining_g)
|
||||
: "—";
|
||||
return `<tr class="${item.shortfall_g > 0 ? "warn" : ""}">
|
||||
<td>${escapeHtml(item.material_type || "—")}</td>
|
||||
<td>${escapeHtml(item.color || "—")}</td>
|
||||
<td class="num">${grams(item.weight_g)}</td>
|
||||
<td class="num">${rest}</td>
|
||||
<td class="num">${missing}</td>
|
||||
<td class="num">${item.jobs || 0}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
document.getElementById("bedarf-rows").innerHTML = rows
|
||||
|| '<tr><td class="empty" colspan="6">Kein Queue-Bedarf.</td></tr>';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[spools, printers, materials, palette] = await Promise.all([
|
||||
const [spoolData, printerData, materialData, paletteData, bedarfData] = await Promise.all([
|
||||
api("/spulen"),
|
||||
api("/drucker?live=0"),
|
||||
api("/materialien"),
|
||||
api("/farben"),
|
||||
api("/bedarf").catch(() => ({ needed: [], shortfall: [] })),
|
||||
]);
|
||||
spools = spoolData;
|
||||
printers = printerData;
|
||||
materials = materialData;
|
||||
palette = paletteData;
|
||||
render();
|
||||
renderPalette();
|
||||
renderBedarf(bedarfData);
|
||||
}
|
||||
|
||||
window.onBedarfChanged = renderBedarf;
|
||||
|
||||
function setScanNote(text, ok) {
|
||||
const note = document.getElementById("scan-note");
|
||||
if (!text) {
|
||||
|
||||
@@ -110,6 +110,21 @@ function fillPrinters(rows) {
|
||||
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 || {};
|
||||
@@ -120,6 +135,16 @@ function render(data) {
|
||||
? (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>
|
||||
@@ -145,6 +170,15 @@ function render(data) {
|
||||
<td class="num">${m.jobs}</td>
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="5">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("Läufe", String(jobs))}
|
||||
@@ -155,6 +189,16 @@ function render(data) {
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
||||
</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].filter(Boolean).join(" · ") || "—")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Nach Drucker</h2>
|
||||
<div class="table-wrap"><table>
|
||||
@@ -222,6 +266,17 @@ document.getElementById("reload").addEventListener("click", () => {
|
||||
document.getElementById("host").addEventListener("change", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
["material", "brand"].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));
|
||||
}
|
||||
});
|
||||
});
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1337,6 +1337,7 @@ async function refresh() {
|
||||
}
|
||||
|
||||
window.onQueueChanged = () => refresh().catch(() => {});
|
||||
window.onDruckerLive = () => refresh().catch(() => {});
|
||||
|
||||
document.getElementById("hide-offline").addEventListener("change", (event) => {
|
||||
hideOffline = event.target.checked;
|
||||
@@ -1627,7 +1628,7 @@ deskEl.addEventListener("keydown", async (event) => {
|
||||
});
|
||||
|
||||
refresh().catch((error) => showNote("note", error));
|
||||
setInterval(() => refresh().catch((error) => showNote("note", error)), 5000);
|
||||
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
|
||||
setInterval(() => {
|
||||
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<p class="tiny">Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<a class="ghost" id="q-bedarf" href="/material#bedarf" hidden></a>
|
||||
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,7 +213,18 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
|
||||
});
|
||||
|
||||
window.onQueueChanged = load;
|
||||
window.onBedarfChanged = (data) => {
|
||||
const link = document.getElementById("q-bedarf");
|
||||
if (!link) return;
|
||||
const n = (data.shortfall || []).length;
|
||||
if (!n) {
|
||||
link.hidden = true;
|
||||
return;
|
||||
}
|
||||
link.hidden = false;
|
||||
link.textContent = n + " Fehlmenge" + (n === 1 ? "" : "n");
|
||||
};
|
||||
load();
|
||||
setInterval(load, 5000);
|
||||
if (typeof refreshBedarf === "function") refreshBedarf();
|
||||
</script>
|
||||
{% endblock %}
|
||||
+70
-23
@@ -5,7 +5,11 @@
|
||||
<h1>Wartung</h1>
|
||||
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<button type="button" class="ghost" id="wartung-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wartung-summary" class="kpi-row" hidden></div>
|
||||
<div id="wartung-root"></div>
|
||||
<p id="wartung-note"></p>
|
||||
{% endblock %}
|
||||
@@ -19,38 +23,77 @@ function partRow(part) {
|
||||
<td class="num">${Math.round(part.meters_since || 0).toLocaleString("de-DE")} mm</td>
|
||||
<td class="num">${part.life_hours ? part.life_hours + " h" : "—"}</td>
|
||||
<td class="num">${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}</td>
|
||||
<td><button type="button" class="ghost sm" data-reset="${part.id}">Zurücksetzen</button></td>
|
||||
<td><button type="button" class="ghost tiny-btn" data-reset="${part.id}">Zurücksetzen</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function printerCard(block) {
|
||||
const c = block.counters || {};
|
||||
const overdue = (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||
const parts = (block.parts || []).map(partRow).join("")
|
||||
|| '<tr><td colspan="6" class="empty">Noch keine Teile.</td></tr>';
|
||||
return `<section class="card">
|
||||
<h2>${escapeHtml(block.name || block.host)}</h2>
|
||||
|| '<tr><td colspan="6" class="empty">Noch keine Teile — unten anlegen.</td></tr>';
|
||||
return `<section class="card wartung-card">
|
||||
<div class="lib-head">
|
||||
<h2>${escapeHtml(block.name || block.host)}</h2>
|
||||
${overdue ? chipHtml("error", overdue + " fällig") : chipHtml("idle", "ok")}
|
||||
</div>
|
||||
<p class="hint">${escapeHtml(block.host)} · ${(c.hours || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h gesamt · ${Math.round(c.meters || 0).toLocaleString("de-DE")} mm · ${c.runs || 0} Läufe</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Seit Reset</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
|
||||
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Filament</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
|
||||
<tbody>${parts}</tbody>
|
||||
</table></div>
|
||||
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}" style="margin-top:0.75rem">
|
||||
<div class="field"><label>Schlüssel</label><input name="part_key" required placeholder="nozzle"></div>
|
||||
<div class="field"><label>Bezeichnung</label><input name="label" placeholder="Düse 0.4"></div>
|
||||
<div class="field"><label>Intervall h</label><input name="life_hours" type="number" step="1" min="0" value="0"></div>
|
||||
<div class="field"><label>Intervall mm</label><input name="life_meters" type="number" step="1000" min="0" value="0"></div>
|
||||
<div class="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
|
||||
<div class="actions"><button type="submit">Teil anlegen</button></div>
|
||||
</form>
|
||||
<details class="mat-more section-gap">
|
||||
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Teil anlegen</span></summary>
|
||||
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}">
|
||||
<div class="field"><label>Schlüssel</label><input name="part_key" required placeholder="nozzle"></div>
|
||||
<div class="field"><label>Bezeichnung</label><input name="label" placeholder="Düse 0.4"></div>
|
||||
<div class="field"><label>Intervall h</label><input name="life_hours" type="number" step="1" min="0" value="0"></div>
|
||||
<div class="field"><label>Intervall mm</label><input name="life_meters" type="number" step="1000" min="0" value="0"></div>
|
||||
<div class="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
|
||||
<div class="actions"><button type="submit">Teil anlegen</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const box = document.getElementById("wartung-summary");
|
||||
if (!data.length) {
|
||||
box.hidden = true;
|
||||
box.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
let hours = 0;
|
||||
let meters = 0;
|
||||
let overdue = 0;
|
||||
data.forEach((block) => {
|
||||
hours += Number((block.counters || {}).hours) || 0;
|
||||
meters += Number((block.counters || {}).meters) || 0;
|
||||
overdue += (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||
});
|
||||
box.hidden = false;
|
||||
box.innerHTML = `
|
||||
<div class="kpi"><div class="kpi-val">${data.length}</div><div class="kpi-label">Drucker</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${hours.toLocaleString("de-DE", { maximumFractionDigits: 0 })} h</div><div class="kpi-label">Laufzeit</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${Math.round(meters / 1000).toLocaleString("de-DE")} m</div><div class="kpi-label">Filament</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${overdue}</div><div class="kpi-label">Fällig</div></div>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const data = await api("/wartung");
|
||||
renderSummary(data || []);
|
||||
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|
||||
|| '<p class="empty">Keine Drucker angelegt.</p>';
|
||||
|| `<div class="empty-state">
|
||||
<svg class="ico"><use href="#i-nozzle"/></svg>
|
||||
<p>Keine Drucker angelegt.</p>
|
||||
<a class="ghost" href="/einstellungen">Einstellungen →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById("wartung-refresh")?.addEventListener("click", () => {
|
||||
load().catch((error) => showNote("wartung-note", error));
|
||||
});
|
||||
|
||||
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
|
||||
const form = event.target.closest("form.wartung-add");
|
||||
if (!form) return;
|
||||
@@ -70,24 +113,28 @@ document.getElementById("wartung-root").addEventListener("submit", async (event)
|
||||
});
|
||||
form.reset();
|
||||
showNote("wartung-note", null, "Teil gespeichert.");
|
||||
toast("Wartungsteil angelegt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("wartung-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wartung-root").addEventListener("click", async (event) => {
|
||||
document.getElementById("wartung-root").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-reset]");
|
||||
if (!btn) return;
|
||||
try {
|
||||
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
|
||||
showNote("wartung-note", null, "Teil zurückgesetzt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("wartung-note", error);
|
||||
}
|
||||
armButton(btn, "Wirklich zurücksetzen?", async () => {
|
||||
try {
|
||||
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
|
||||
showNote("wartung-note", null, "Teil zurückgesetzt.");
|
||||
toast("Zähler zurückgesetzt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("wartung-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
load();
|
||||
load().catch((error) => showNote("wartung-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user