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:
Dominic Gundel committed 2026-10-18 02:20:12 +02:00
1 parent 8e88efc094
commit 6e59df22e5
23 files changed
+741 -171

No files matched your search

+180 -70
View File
@@ -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();
});
})();