diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9a4f6..d8e0746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +## [3.1.0] – 2026-10-18 + +### Neu + +- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange. +- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster). +- **HT/NT-Tarif** in den Energie-Einstellungen. +- **ntfy-Test** wie bei Telegram. +- **Helles Theme** (Dunkel / Hell / System) pro Browser. +- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten. +- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`). + +### Geändert + +- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall. +- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens. +- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`). + +### Behoben + +- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`). +- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten. +- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen. + ## [3.0.0] – 2026-10-18 Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen. diff --git a/config.example.json b/config.example.json index 31a7886..62c2f00 100644 --- a/config.example.json +++ b/config.example.json @@ -23,7 +23,13 @@ "dryer_watts": 0, "dry_hours": 0, "waste_percent": 0, - "utilization": 50 + "utilization": 50, + "tariff": { + "ht_euro": 0.35, + "nt_euro": 0.22, + "nt_from": "22:00", + "nt_to": "06:00" + } }, "telegram": { "enabled": false, diff --git a/docs/Anleitung-Einstellungen.md b/docs/Anleitung-Einstellungen.md index e18fe8d..bdd5b36 100644 --- a/docs/Anleitung-Einstellungen.md +++ b/docs/Anleitung-Einstellungen.md @@ -1,12 +1,13 @@ # Einstellungen -Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie. +Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie. ![Einstellungen: Drucker](../web/static/doku/einstellungen-drucker.png) ## Dieser Browser - **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md). +- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät). - **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität. ## Automatik @@ -19,29 +20,30 @@ Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordn ## Drucker -Tabelle: Name, Host, Familie, **gesperrt**. +Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**. - Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank). - Familie aus dem Namen (Kobra 3 / S1) oder gesetzt. - **Gesperrt:** keine neuen Queue-Starts auf diesem Host. +- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs. ## FQDN Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt. -## Telegram und Alerts +## Telegram, ntfy und Alerts -Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts. +Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts. ## Benutzer und API-Tokens -Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). +Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr. MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`. ## Energie -**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation. +**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. ## Weiter diff --git a/docs/Anleitung-Material.md b/docs/Anleitung-Material.md index acb4cb2..3cfd854 100644 --- a/docs/Anleitung-Material.md +++ b/docs/Anleitung-Material.md @@ -1,6 +1,6 @@ # Material und mehr -Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen. +Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Kameras, Aktivität. ![Material: Spulen](../web/static/doku/material-spule.png) @@ -8,6 +8,8 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras Zwei Reiter: **Spulen** und **Sorten**. +Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange. + **Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in. **Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter. @@ -18,15 +20,15 @@ Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Materia Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht. -## Projekte +## Aufträge -Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden). +Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md). ## Protokoll und Statistik Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option. -Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten. +Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis. Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll. diff --git a/docs/Anleitung-Oberflaeche.md b/docs/Anleitung-Oberflaeche.md index b82c650..dcb7f2b 100644 --- a/docs/Anleitung-Oberflaeche.md +++ b/docs/Anleitung-Oberflaeche.md @@ -1,18 +1,18 @@ # Oberfläche -Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal. +Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal. ## Navigation -Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku). +Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Warteschlange**, **Aufträge**, **Protokoll**, **Statistik**, **Aktivität**, **Material**, **Wartung**. Darunter **Einstellungen**, **Doku**. Fuß: Version (öffnet die Doku). -Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen. +Unter ca. 760 px: untere Leiste plus **Mehr**-Sheet. Das Seitenmenü lässt sich zur Icon-Rail einklappen. ![Seitenleiste mit Live-Boxen](../web/static/doku/sidebar-live.png) ## Live-Leiste -Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser): +Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser). Aktualisierung über Live-Stream (SSE), sonst Polling. | Box | Inhalt | | --- | --- | @@ -21,6 +21,7 @@ Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Brow | **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). | | **Senden** | Uploads mit MB-Fortschritt. | | **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. | +| **Bedarf** | Fehlende Filamentmenge für die Queue (Link zu Material). | ## Desktop und Regal @@ -28,6 +29,7 @@ Unter **Einstellungen → Dieser Browser**: - **Desktop** — normale Dichte - **Regal (Touch)** — größere Bedienelemente, kompaktere Nav, Sidebar oft eingeklappt +- **Darstellung** — Dunkel, Hell oder System - **Zeitzone** — Browser oder fest (z. B. Europe/Berlin) für Uhrzeiten in Protokoll und Bericht Shortcut in der URL: `?shelf=1` bzw. `?shelf=0`. Nur dieser Browser (`localStorage`). diff --git a/docs/Anleitung-Warteschlange.md b/docs/Anleitung-Warteschlange.md index 5ff4872..a1fbc0a 100644 --- a/docs/Anleitung-Warteschlange.md +++ b/docs/Anleitung-Warteschlange.md @@ -1,6 +1,6 @@ # Warteschlange und Ablauf -Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten). +Stand **18.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten). ![Warteschlange](../web/static/doku/warteschlange.png) @@ -18,9 +18,11 @@ Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand). Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab. +Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**. + ## Wann startet ein wartender Auftrag? -Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. +Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis. ## Ablauf (nur Kobra 3) diff --git a/grotti/bedarf.py b/grotti/bedarf.py index db97e19..cf89463 100644 --- a/grotti/bedarf.py +++ b/grotti/bedarf.py @@ -15,7 +15,7 @@ def compute(conn): queue_rows = conn.execute( """ SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done, - f.weight_g, f.material_type AS file_material, f.color AS file_color, + f.weight_g, f.filaments, s.material_type AS spool_material, s.color AS spool_color FROM print_queue q JOIN files f ON f.id = q.file_id @@ -26,8 +26,12 @@ def compute(conn): needed_map = {} for row in queue_rows: - mat = (row.get("spool_material") or row.get("file_material") or "").strip() - color = (row.get("spool_color") or row.get("file_color") or "").strip() + filaments = row.get("filaments") or [] + first = filaments[0] if isinstance(filaments, list) and filaments else {} + if not isinstance(first, dict): + first = {} + mat = (row.get("spool_material") or first.get("type") or "").strip() + color = (row.get("spool_color") or first.get("color") or "").strip() key = _group_key(mat, color) try: repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0)) diff --git a/grotti/config.py b/grotti/config.py index 0713166..1356bd4 100644 --- a/grotti/config.py +++ b/grotti/config.py @@ -300,7 +300,7 @@ def _save_settings_locked( "utilization": float(energy.get("utilization", 50)), } tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None - if tariff_in is not None: + if tariff_in: raw_energy["tariff"] = { "ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])), "nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])), diff --git a/grotti/store.py b/grotti/store.py index 277ff9f..71a4619 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -2081,20 +2081,28 @@ def create_printer(conn, printer_id, name, host, family): return printer_out(row) -def update_printer(conn, printer_id, name, host, family, locked=None): +def update_printer(conn, printer_id, name, host, family, locked=None, start_window=None): current = printer_by_id(conn, printer_id) if not current: return None if locked is None: locked = bool(current.get("locked")) + if start_window is None: + sw = current.get("start_window") or {} + elif isinstance(start_window, dict): + sw = start_window + else: + sw = {} + if not isinstance(sw, dict): + sw = {} row = conn.execute( """ UPDATE printers - SET name = %s, host = %s, family = %s, locked = %s + SET name = %s, host = %s, family = %s, locked = %s, start_window = %s WHERE id = %s RETURNING * """, - (name, host, family, bool(locked), printer_id), + (name, host, family, bool(locked), Json(sw), printer_id), ).fetchone() if current["host"] != host: old = current["host"] diff --git a/grotti/version.py b/grotti/version.py index d4ee142..abd3c67 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "3.0.0" +__version__ = "3.1.0" diff --git a/tests/test_bedarf.py b/tests/test_bedarf.py index 634257a..5c06f05 100644 --- a/tests/test_bedarf.py +++ b/tests/test_bedarf.py @@ -26,8 +26,7 @@ class BedarfTests(unittest.TestCase): "repeat_total": 2, "repeat_done": 0, "weight_g": 50.0, - "file_material": "PLA", - "file_color": "Schwarz", + "filaments": [{"type": "PLA", "color": "Schwarz"}], "spool_material": "PLA", "spool_color": "Schwarz", }, @@ -52,8 +51,7 @@ class BedarfTests(unittest.TestCase): "repeat_total": 1, "repeat_done": 0, "weight_g": 20.0, - "file_material": "PETG", - "file_color": "", + "filaments": [{"type": "PETG", "color": ""}], "spool_material": None, "spool_color": None, }, diff --git a/web/app.py b/web/app.py index c9ca7ac..3d72c96 100644 --- a/web/app.py +++ b/web/app.py @@ -103,6 +103,7 @@ def create_app(cfg): ) ADMIN_API_EXACT = { "/api/v1/telegram/test", + "/api/v1/ntfy/test", } ADMIN_METHODS_ON = { # Drucker anlegen/ändern/löschen nur Admin @@ -135,14 +136,6 @@ def create_app(cfg): return user def resolve_bearer(presented): - legacy = cfg.get("token") or "" - if legacy and secrets.compare_digest(presented, legacy): - return { - "id": None, - "username": "legacy-token", - "role": "admin", - "auth": "legacy", - } if not presented.startswith(grotti_auth.TOKEN_PREFIX): return None row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented)) @@ -821,8 +814,17 @@ def create_app(cfg): ) return jsonify({"ok": True}) + @app.post("/api/v1/ntfy/test") + def ntfy_test(): + ok = notify.send_ntfy(cfg, "Grotti-Test: ntfy ist verbunden.") + if not ok: + raise GrottiError( + "ntfy nicht erreichbar. Server, Topic und „aktiv“ prüfen.", + "ntfy", + ) + return jsonify({"ok": True}) + @app.get("/api/v1/drucker") - @app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker def printers(): rows = store.list_printers(conn()) clears = store.bed_clear_map(conn()) @@ -908,7 +910,18 @@ def create_app(cfg): if other and str(other["id"]) != str(printer_id): raise GrottiError("Diesen Host gibt es schon.", "host") locked = data.get("locked") if "locked" in data else current.get("locked") - row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked)) + start_window = data.get("start_window") if "start_window" in data else None + if start_window is not None and not isinstance(start_window, dict): + raise GrottiError("Zeitfenster muss ein Objekt sein.", "start_window") + row = store.update_printer( + conn(), + printer_id, + name, + host, + family, + locked=bool(locked), + start_window=start_window, + ) return jsonify(row) @app.delete("/api/v1/drucker/id/") @@ -2388,12 +2401,12 @@ def create_app(cfg): finally: events_bus.unsubscribe(q) + # Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333). return Response( stream_with_context(generate()), mimetype="text/event-stream; charset=utf-8", headers={ "Cache-Control": "no-cache", - "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) diff --git a/web/static/api.js b/web/static/api.js index 54d7400..f9219e2 100644 --- a/web/static/api.js +++ b/web/static/api.js @@ -28,13 +28,29 @@ async function api(path, options = {}) { headers.set("Content-Type", "application/json"); body = JSON.stringify(options.json); } - const response = await fetch("/api/v1" + path, { - method, - headers, - body, - cache: "no-store", - credentials: "same-origin", - }); + const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000; + const ctrl = options.signal ? null : new AbortController(); + const timer = ctrl && timeoutMs > 0 + ? setTimeout(() => ctrl.abort(), timeoutMs) + : 0; + let response; + try { + response = await fetch("/api/v1" + path, { + method, + headers, + body, + cache: "no-store", + credentials: "same-origin", + signal: options.signal || (ctrl && ctrl.signal), + }); + } catch (error) { + if (error && error.name === "AbortError") { + throw new Error("Zeitüberschreitung — Server antwortet nicht."); + } + throw error; + } finally { + if (timer) clearTimeout(timer); + } if (response.status === 401) { const here = location.pathname + location.search; if (!here.startsWith("/login") && !here.startsWith("/setup")) { diff --git a/web/static/grotti.css b/web/static/grotti.css index f124787..c4bb2cc 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -2398,7 +2398,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } .nav-main { display: none; } html.shelf .mobile-nav, body.shelf .mobile-nav { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(5, 1fr); } .nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; } .side-foot { display: none; } @@ -2421,7 +2421,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } .desk:not(.no-cam) .desk-cam { max-height: 18rem; } .mobile-nav { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(5, 1fr); position: fixed; left: 0; right: 0; @@ -2631,6 +2631,99 @@ ul.plain li:last-child { border-bottom: 0; } } } +html[data-theme="light"] { + color-scheme: light; + --bg: #f4f4f6; + --ink: #16161a; + --muted: #5c5c66; + --faint: #6e6e78; + --line: rgba(0, 0, 0, 0.08); + --line-strong: rgba(0, 0, 0, 0.14); + --card: #ffffff; + --card-2: #f0f0f3; + --accent: #ea580c; + --accent-ink: #fff7ed; + --accent-soft: rgba(234, 88, 12, 0.12); + --warn: #dc2626; + --pause: #d97706; + --track: #e4e4ea; + --shadow: 0 10px 28px rgba(20, 20, 30, 0.08); + --live: #16a34a; + --live-soft: rgba(22, 163, 74, 0.12); + --st-printing: #16a34a; + --st-printing-soft: rgba(22, 163, 74, 0.1); + --st-heating: #d97706; + --st-heating-soft: rgba(217, 119, 6, 0.1); + --st-done: #4f46e5; + --st-done-soft: rgba(79, 70, 229, 0.1); + --st-error: #dc2626; + --st-error-soft: rgba(220, 38, 38, 0.1); + --st-offline: #73737c; + --st-offline-soft: rgba(0, 0, 0, 0.04); + --st-waiting: #ea580c; + --st-waiting-soft: rgba(234, 88, 12, 0.1); + --st-idle: #5c5c66; + --st-idle-soft: rgba(0, 0, 0, 0.04); +} +html[data-theme="light"] body { + background: + radial-gradient(800px 400px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%), + var(--bg); +} +html[data-theme="light"] a:hover { color: #c2410c; } +html[data-theme="light"] { + scrollbar-color: rgba(0, 0, 0, 0.18) transparent; +} +html[data-theme="light"]::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.18); } +html[data-theme="light"]::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); } + +.narrow-fields { max-width: 36rem; } +.printer-edit-list { display: grid; gap: 0.85rem; } +.printer-edit { + border: 1px solid var(--line); + border-radius: var(--radius-sm); + padding: 0.85rem 1rem; + background: var(--card-2); +} +.printer-edit .actions { margin-top: 0.65rem; gap: 0.5rem; } +.bedarf-card { margin-bottom: 1rem; } +.bedarf-card tr.warn td { color: var(--warn); } +.wartung-card .lib-head { align-items: center; } + +.bar-chart { display: grid; gap: 0.45rem; margin-top: 0.35rem; } +.bar-row { + display: grid; + grid-template-columns: minmax(5rem, 8rem) 1fr auto; + gap: 0.55rem; + align-items: center; +} +.bar-label { + font-size: var(--t-sm); + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bar-track { + height: 0.55rem; + border-radius: 99px; + background: var(--track); + overflow: hidden; +} +.bar-fill { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #fbbf24)); +} +.bar-val { + font-size: var(--t-xs); + font-variant-numeric: tabular-nums; + color: var(--faint); + min-width: 3.5rem; + text-align: right; +} + @media print { .sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; } .app-shell { display: block; } diff --git a/web/static/sw.js b/web/static/sw.js index e2ae8af..ac80413 100644 --- a/web/static/sw.js +++ b/web/static/sw.js @@ -1,27 +1,46 @@ -/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */ -const CACHE = "grotti-shell-v1"; -const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"]; +/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */ +const CACHE = "grotti-shell-v2"; self.addEventListener("install", (event) => { - event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined))); self.skipWaiting(); + event.waitUntil( + caches.open(CACHE).then((cache) => + cache.addAll([ + "/static/grotti.css?v=56", + "/static/api.js?v=29", + "/static/ui.js?v=3", + ]).catch(() => undefined) + ) + ); }); self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) - ) + ).then(() => self.clients.claim()) ); - self.clients.claim(); }); self.addEventListener("fetch", (event) => { const req = event.request; if (req.method !== "GET") return; + // Dokument-Navigation immer ans Netz — kein Fallback auf „/“. + if (req.mode === "navigate") return; const url = new URL(req.url); + if (url.origin !== self.location.origin) return; if (url.pathname.startsWith("/api/")) return; + if (!url.pathname.startsWith("/static/")) return; + event.respondWith( - fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/"))) + fetch(req) + .then((res) => { + if (res.ok) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {}); + } + return res; + }) + .catch(() => caches.match(req)) ); }); diff --git a/web/templates/auftraege.html b/web/templates/auftraege.html index 7f4e564..feca7a6 100644 --- a/web/templates/auftraege.html +++ b/web/templates/auftraege.html @@ -203,16 +203,22 @@ function renderList() { body.innerHTML = 'Noch kein Auftrag.'; 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 = ""; 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); }); diff --git a/web/templates/einstellungen.html b/web/templates/einstellungen.html index 3481a5f..cbc5d04 100644 --- a/web/templates/einstellungen.html +++ b/web/templates/einstellungen.html @@ -10,25 +10,34 @@

Dieser Browser

Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.

-
- - +
+
+ + +
+
+ + +
+
+ + +
-

Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: ?shelf=1

-
- - -
-

Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.

+

Regal: größere Bedienelemente — Shortcut ?shelf=1. Darstellung und Zeitzone nur dieser Browser.

@@ -68,14 +77,9 @@

Drucker

-

Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.

-
- - - -
NameHostFamilieGesperrt
-
-
+

Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).

+
+
@@ -143,8 +147,11 @@
-

Gleiche Meldungen wie Telegram — unabhängig abschaltbar.

-
+

Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.

+
+ + +

@@ -171,7 +178,15 @@
-

Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.

+

HT / NT-Tarif

+ + +

Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.

@@ -217,7 +232,7 @@

- + {% 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 = 'Keine Drucker.'; + root.innerHTML = '

Keine Drucker.

'; return; } rows.forEach((row) => { - const tr = document.createElement("tr"); - tr.innerHTML = ``; - 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 = ` +
+
+
+
+
+
+
+ Start-Zeitfenster + ${win.enabled ? escapeHtml(win.from) + "–" + escapeHtml(win.to) : "aus"} + +
+ +
+
+
+
+
+
`; + 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(); }); })(); diff --git a/web/templates/layout.html b/web/templates/layout.html index 575e518..19967cd 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -9,11 +9,27 @@ - + - + @@ -132,6 +156,11 @@
 alle →
+ -
+
- - + + {% block script %}{% endblock %} diff --git a/web/templates/material.html b/web/templates/material.html index b35b481..f82e0e0 100644 --- a/web/templates/material.html +++ b/web/templates/material.html @@ -14,6 +14,20 @@ + +
@@ -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 ` + ${escapeHtml(item.material_type || "—")} + ${escapeHtml(item.color || "—")} + ${grams(item.weight_g)} + ${rest} + ${missing} + ${item.jobs || 0} + `; + }).join(""); + document.getElementById("bedarf-rows").innerHTML = rows + || 'Kein Queue-Bedarf.'; +} + 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) { diff --git a/web/templates/statistik.html b/web/templates/statistik.html index 5e54430..c6a333a 100644 --- a/web/templates/statistik.html +++ b/web/templates/statistik.html @@ -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 '

Keine Werte fürs Diagramm.

'; + const max = Math.max(...rows.map((item) => Number(item[valueKey]) || 0)) || 1; + return ``; +} + 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 = ` +
+ +

Keine Läufe im gewählten Zeitraum.

+ Protokoll → +
`; + return; + } + const printers = (data.by_printer || []).map((p) => ` ${escapeHtml(p.printer_name || p.printer_host)} @@ -145,6 +170,15 @@ function render(data) { ${m.jobs} `).join("") || 'Kein Spulenabzug im Zeitraum.'; + 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 = `
${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")}
+
+
+

Druckzeit je Drucker

+ ${barChart(chartPrinters, "duration_s", (p) => p.printer_name || p.printer_host || "—")} +
+
+

Verbrauch je Material

+ ${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")} +
+

Nach Drucker

@@ -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)); {% endblock %} diff --git a/web/templates/wand.html b/web/templates/wand.html index 9d153e6..bdf2c4f 100644 --- a/web/templates/wand.html +++ b/web/templates/wand.html @@ -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(() => {}); diff --git a/web/templates/warteschlange.html b/web/templates/warteschlange.html index 7b2b3c6..69ef03f 100644 --- a/web/templates/warteschlange.html +++ b/web/templates/warteschlange.html @@ -6,6 +6,7 @@

Wartende und laufende Aufträge. Direktstarts erscheinen unter Läuft. Priorität mit den Pfeilen ändern.

+
@@ -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(); {% endblock %} diff --git a/web/templates/wartung.html b/web/templates/wartung.html index da99cbb..d38c9af 100644 --- a/web/templates/wartung.html +++ b/web/templates/wartung.html @@ -5,7 +5,11 @@

Wartung

Laufstunden und Filament je Drucker — Verschleißteile im Blick.

+
+ +
+

{% endblock %} @@ -19,38 +23,77 @@ function partRow(part) { - + `; } 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("") - || ''; - return `
-

${escapeHtml(block.name || block.host)}

+ || '
'; + return `
+
+

${escapeHtml(block.name || block.host)}

+ ${overdue ? chipHtml("error", overdue + " fällig") : chipHtml("idle", "ok")} +

${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

${Math.round(part.meters_since || 0).toLocaleString("de-DE")} mm ${part.life_hours ? part.life_hours + " h" : "—"} ${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}
Noch keine Teile.
Noch keine Teile — unten anlegen.
- + ${parts}
TeilSeit ResetSeit ResetIntervall hIntervall mm
TeilSeit ResetFilamentIntervall hIntervall mm
-
-
-
-
-
-
-
-
+
+ Teil anlegen +
+
+
+
+
+
+
+
+
`; } +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 = ` +
${data.length}
Drucker
+
${hours.toLocaleString("de-DE", { maximumFractionDigits: 0 })} h
Laufzeit
+
${Math.round(meters / 1000).toLocaleString("de-DE")} m
Filament
+
${overdue}
Fällig
`; +} + async function load() { const data = await api("/wartung"); + renderSummary(data || []); document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("") - || '

Keine Drucker angelegt.

'; + || `
+ +

Keine Drucker angelegt.

+ Einstellungen → +
`; } +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)); {% endblock %}