Grotti 3.0.0: Aufträge, Wartung, SSE, Ausschuss-Dialog, UI-Umbau.

Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil,
ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
This commit is contained in:
Dominic Gundel committed 2026-10-18 00:54:51 +02:00
1 parent 0236278929
commit 8e88efc094
45 files changed
+3121 -122

No files matched your search

+348 -6
View File
@@ -11,7 +11,7 @@ from urllib.parse import quote
import markdown
import psycopg
from flask import Flask, abort, g, jsonify, redirect, render_template, request, session, url_for
from flask import Flask, abort, g, jsonify, redirect, render_template, request, send_file, session, url_for
from markupsafe import Markup
from psycopg.rows import dict_row
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -21,13 +21,15 @@ from grotti import config as grotti_config
from grotti import db as grotti_db
from grotti import eject, jobs, library, moonraker, notify, store
from grotti.catalog import color_catalog
from grotti.cost import calculate
from grotti.cost import calculate, offer_from_file, order_actuals
from grotti import orders as grotti_orders
from grotti.errors import GrottiError
from grotti.printers import FAMILY_LABELS, family_from_name
from grotti.print_queue import PrintQueueDispatcher
from grotti.send_queue import SendQueue
from grotti.toolpath import TOOLPATH_VERSION, build_toolpath
from grotti.version import __version__
from grotti import verdict as grotti_verdict
from grotti.watcher import StateWatcher
ROOT = Path(__file__).resolve().parent
@@ -35,6 +37,7 @@ TOOL_ROOT = ROOT.parent
DOCS_DIR = TOOL_ROOT / "docs"
PROJECT_STATUS = {"idee", "bereit", "druckt", "fertig"}
VERDICTS = {"", "gut", "ausschuss"}
VERDICT_REASONS = sorted(grotti_verdict.VERDICT_REASONS)
IDEA_STATUS = {"offen", "spaeter", "erledigt"}
PRINTER_FAMILIES = set(FAMILY_LABELS) | {""}
UPLOAD_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
@@ -96,6 +99,7 @@ def create_app(cfg):
ADMIN_API_PREFIXES = (
"/api/v1/users",
"/api/v1/einstellungen",
"/api/v1/backup",
)
ADMIN_API_EXACT = {
"/api/v1/telegram/test",
@@ -566,7 +570,9 @@ def create_app(cfg):
"Anleitung-Orca",
"Anleitung-ACE",
"Anleitung-Warteschlange",
"Anleitung-Auftraege",
"Anleitung-Material",
"Anleitung-Wartung",
"Anleitung-Einstellungen",
"Anleitung-Stoerungen",
)
@@ -587,7 +593,9 @@ def create_app(cfg):
"Anleitung-Orca": "Orca an Grotti",
"Anleitung-ACE": "ACE und Spulen",
"Anleitung-Warteschlange": "Warteschlange",
"Anleitung-Auftraege": "Aufträge",
"Anleitung-Material": "Material und mehr",
"Anleitung-Wartung": "Wartung",
"Anleitung-Einstellungen": "Einstellungen",
"Anleitung-Stoerungen": "Störungen",
}
@@ -620,7 +628,23 @@ def create_app(cfg):
@app.get("/projekte")
def projekte():
return page("projekte.html", "Projekte")
return redirect("/auftraege", code=301)
@app.get("/auftraege")
def auftraege_page():
return page("auftraege.html", "Aufträge")
@app.get("/auftraege/<int:order_id>/lieferschein")
def auftrag_lieferschein(order_id):
row = grotti_orders.get_order(conn(), order_id)
if not row:
abort(404)
return render_template(
"lieferschein.html",
page="auftraege",
title="Lieferschein #%s" % order_id,
order=row,
)
@app.get("/einstellungen")
def einstellungen_page():
@@ -666,6 +690,10 @@ def create_app(cfg):
def statistik_page():
return page("statistik.html", "Statistik")
@app.get("/wartung")
def wartung_page():
return page("wartung.html", "Wartung")
@app.get("/etikett/lauf/<int:run_id>")
def etikett_lauf(run_id):
row = store.get_run_row(conn(), run_id)
@@ -709,7 +737,7 @@ def create_app(cfg):
@app.get("/ideen")
def ideen():
return page("ideen.html", "Ideen")
return redirect("/auftraege", code=301)
@app.get("/aktivitaet")
def aktivitaet():
@@ -767,6 +795,7 @@ def create_app(cfg):
data = body()
energy = data.get("energy") if isinstance(data.get("energy"), dict) else None
telegram = data.get("telegram") if isinstance(data.get("telegram"), dict) else None
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), dict) else None
alerts = data.get("alerts") if isinstance(data.get("alerts"), dict) else None
auto_queue = data.get("auto_queue") if "auto_queue" in data else None
fqdn = data.get("fqdn") if "fqdn" in data else None
@@ -775,6 +804,7 @@ def create_app(cfg):
cfg,
energy=energy,
telegram=telegram,
ntfy=ntfy,
alerts=alerts,
auto_queue=auto_queue,
fqdn=fqdn,
@@ -1399,8 +1429,31 @@ def create_app(cfg):
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
require_host(host)
store.set_bed_needs_clear(conn(), host, False, "quittiert")
return jsonify({"ok": True, "host": host, "needs_clear": False, "message": "Platte quittiert."})
c = conn()
store.set_bed_needs_clear(c, host, False, "quittiert")
run_json = None
if any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
run_row = store.run_for_abraeumen_verdict(c, host)
if not run_row:
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
try:
verdict, reason, ok_n, bad_n = grotti_verdict.normalize_abraeumen_verdict(
data.get("verdict"),
data.get("verdict_reason"),
data.get("pieces_ok"),
data.get("pieces_bad"),
)
except ValueError as exc:
raise GrottiError(str(exc), "urteil") from exc
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
return jsonify({
"ok": True,
"host": host,
"needs_clear": False,
"message": "Platte quittiert.",
"run": run_json,
"verdict_reasons": VERDICT_REASONS,
})
@app.get("/api/v1/abraeumen-defaults")
def abraeumen_defaults():
@@ -1667,6 +1720,191 @@ def create_app(cfg):
raise GrottiError("Die Idee fehlt.", "idee", 404)
return "", 204
@app.get("/api/v1/kunden")
def kunden_list():
return jsonify(grotti_orders.list_customers(conn()))
@app.post("/api/v1/kunden")
def kunden_create():
data = body()
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Der Kunde braucht einen Namen.", "name")
return jsonify(
grotti_orders.create_customer(
conn(),
name,
(data.get("email") or "").strip(),
(data.get("notes") or "").strip(),
)
), 201
@app.patch("/api/v1/kunden/<int:customer_id>")
def kunden_patch(customer_id):
data = body()
fields = {}
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise GrottiError("Der Kunde braucht einen Namen.", "name")
fields["name"] = name
if "email" in data:
fields["email"] = (data.get("email") or "").strip()
if "notes" in data:
fields["notes"] = (data.get("notes") or "").strip()
row = grotti_orders.update_customer(conn(), customer_id, fields)
if not row:
raise GrottiError("Der Kunde fehlt.", "kunde", 404)
return jsonify(row)
@app.delete("/api/v1/kunden/<int:customer_id>")
def kunden_delete(customer_id):
if not grotti_orders.delete_customer(conn(), customer_id):
raise GrottiError("Der Kunde fehlt.", "kunde", 404)
return "", 204
@app.get("/api/v1/auftraege")
def auftraege_list():
status = (request.args.get("status") or "").strip().lower()
if status and status not in grotti_orders.ORDER_STATUSES:
raise GrottiError("Unbekannter Status.", "status")
return jsonify(grotti_orders.list_orders(conn(), status=status or None))
@app.post("/api/v1/auftraege")
def auftraege_create():
data = body()
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Der Auftrag braucht einen Titel.", "titel")
try:
status = grotti_orders.normalize_status(data.get("status") or "idee")
except ValueError as exc:
raise GrottiError(str(exc), "status") from exc
cid = optional_id(data.get("customer_id"))
return jsonify(
grotti_orders.create_order(
conn(),
title,
status,
customer_id=cid,
due_at=data.get("due_at"),
notes=(data.get("notes") or "").strip(),
quote=data.get("quote") if isinstance(data.get("quote"), dict) else {},
)
), 201
@app.get("/api/v1/auftraege/<int:order_id>")
def auftrag_get(order_id):
row = grotti_orders.get_order(conn(), order_id)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
row["actuals"] = order_actuals(conn(), order_id)
return jsonify(row)
@app.patch("/api/v1/auftraege/<int:order_id>")
def auftrag_patch(order_id):
data = body()
fields = {}
if "title" in data:
title = (data.get("title") or "").strip()
if not title:
raise GrottiError("Der Auftrag braucht einen Titel.", "titel")
fields["title"] = title
if "status" in data:
try:
fields["status"] = grotti_orders.normalize_status(data.get("status"))
except ValueError as exc:
raise GrottiError(str(exc), "status") from exc
if "customer_id" in data:
fields["customer_id"] = optional_id(data.get("customer_id"))
if "due_at" in data:
fields["due_at"] = data.get("due_at")
if "notes" in data:
fields["notes"] = data.get("notes") or ""
if "quote" in data and isinstance(data.get("quote"), dict):
fields["quote"] = data["quote"]
row = grotti_orders.update_order(conn(), order_id, fields)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
row["actuals"] = order_actuals(conn(), order_id)
return jsonify(row)
@app.delete("/api/v1/auftraege/<int:order_id>")
def auftrag_delete(order_id):
if not grotti_orders.delete_order(conn(), order_id):
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return "", 204
@app.post("/api/v1/auftraege/<int:order_id>/positionen")
def auftrag_position_create(order_id):
data = body()
file_id = optional_id(data.get("file_id"))
row = grotti_orders.create_order_item(
conn(),
order_id,
file_id=file_id,
qty=data.get("qty", 1),
unit_price=data.get("unit_price", 0),
material_type=(data.get("material_type") or "").strip(),
color=(data.get("color") or "").strip(),
note=(data.get("note") or "").strip(),
)
if not row:
raise GrottiError("Der Auftrag fehlt.", "auftrag", 404)
return jsonify(row), 201
@app.patch("/api/v1/auftraege/positionen/<int:item_id>")
def auftrag_position_patch(item_id):
data = body()
fields = {}
for key in ("file_id", "qty", "unit_price", "material_type", "color", "note"):
if key in data:
fields[key] = data[key] if key != "file_id" else optional_id(data.get("file_id"))
row = grotti_orders.update_order_item(conn(), item_id, fields)
if not row:
raise GrottiError("Die Position fehlt.", "position", 404)
return jsonify(row)
@app.delete("/api/v1/auftraege/positionen/<int:item_id>")
def auftrag_position_delete(item_id):
if not grotti_orders.delete_order_item(conn(), item_id):
raise GrottiError("Die Position fehlt.", "position", 404)
return "", 204
@app.post("/api/v1/auftraege/<int:order_id>/einreihen")
def auftrag_einreihen(order_id):
data = body()
if not bool(data.get("confirm")):
raise GrottiError("Bestätigung fehlt.", "bestaetigung")
try:
item_id = int(data.get("item_id"))
except (TypeError, ValueError) as exc:
raise GrottiError("Position fehlt.", "position") from exc
host = optional_host(data.get("host") or "")
family = (data.get("family") or "").strip()
if not host and not family:
file_row = None
item = grotti_orders.get_order_item(conn(), item_id)
if item and item.get("file_id"):
file_row = store.get_file_row(conn(), item["file_id"])
family = (file_row or {}).get("printer_family") or ""
try:
row = grotti_orders.enqueue_order_item(
conn(),
order_id,
item_id,
host=host,
family=family,
priority=int(data.get("priority", 100)),
spool_id=optional_id(data.get("spool_id")),
ace_map=data.get("ace_map") if isinstance(data.get("ace_map"), list) else [],
bed_leveling=bool(data.get("bed_leveling")),
override=bool(data.get("override_machine")),
)
except ValueError as exc:
raise GrottiError(str(exc), "auftrag") from exc
return jsonify(row), 201
@app.get("/api/v1/spulen")
def spool_list():
return jsonify(store.list_spools(conn()))
@@ -1989,6 +2227,75 @@ def create_app(cfg):
row = store.run_out(current)
return jsonify(row)
@app.get("/api/v1/bedarf")
def bedarf_api():
from grotti import bedarf as bedarf_mod
return jsonify(bedarf_mod.compute(conn()))
@app.get("/api/v1/wartung")
def wartung_api():
from grotti import maintenance
return jsonify(maintenance.list_for_api(conn(), store.list_printers(conn())))
@app.post("/api/v1/wartung/teile")
def wartung_teil_create():
from grotti import maintenance
data = body()
host = validate_printer_host(data.get("printer_host") or data.get("host") or "")
require_host(host)
try:
part = maintenance.create_part(
conn(),
host,
data.get("part_key") or "",
label=data.get("label") or "",
life_hours=data.get("life_hours") or 0,
life_meters=data.get("life_meters") or 0,
note=data.get("note") or "",
)
except ValueError as exc:
raise GrottiError(str(exc), "wartung") from exc
conn().commit()
return jsonify(part)
@app.post("/api/v1/wartung/teile/<int:part_id>/reset")
def wartung_teil_reset(part_id):
from grotti import maintenance
part = maintenance.reset_part(conn(), part_id)
if not part:
raise GrottiError("Teil fehlt.", "wartung", 404)
conn().commit()
return jsonify(part)
@app.post("/api/v1/backup")
def backup_create():
from grotti import backup as backup_mod
try:
meta = backup_mod.create_backup(cfg)
except RuntimeError as exc:
raise GrottiError(str(exc), "backup") from exc
return jsonify(meta)
@app.get("/api/v1/backup")
def backup_list():
from grotti import backup as backup_mod
return jsonify({"items": backup_mod.list_backups(cfg)})
@app.get("/api/v1/backup/<name>")
def backup_download(name):
from grotti import backup as backup_mod
path = backup_mod.backup_path(cfg, name)
if not path:
raise GrottiError("Backup fehlt.", "backup", 404)
return send_file(path, as_attachment=True, download_name=path.name)
@app.get("/api/v1/statistik")
def statistik_api():
since = request.args.get("since") or None
@@ -2056,4 +2363,39 @@ def create_app(cfg):
headers={"Content-Disposition": "attachment; filename=grotti-laeufe.csv"},
)
@app.get("/api/v1/stream")
def api_event_stream():
from flask import Response, stream_with_context
from grotti import events_bus
def generate():
q = events_bus.subscribe()
try:
while True:
item = events_bus.wait_event(q, 15.0)
if item is None:
yield "event: ping\ndata: {}\n\n"
continue
kind = item.get("kind") or "message"
payload = item.get("payload") or {}
yield "event: %s\ndata: %s\n\n" % (
kind,
json.dumps(payload, ensure_ascii=False),
)
except GeneratorExit:
pass
finally:
events_bus.unsubscribe(q)
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",
},
)
return app
+9 -2
View File
@@ -12,7 +12,8 @@ function escapeHtml(value) {
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
async function api(path, options = {}) {
@@ -277,10 +278,16 @@ function toast(message, kind) {
host.setAttribute("aria-live", "polite");
document.body.appendChild(host);
}
const text = String(message ?? "");
host.querySelectorAll(".toast").forEach((node) => {
const body = node.querySelector("div");
if (body && body.textContent === text) node.remove();
});
while (host.children.length >= 3) host.firstElementChild?.remove();
const node = document.createElement("div");
node.className = "toast " + (kind || "good");
node.innerHTML = icon(kind === "bad" ? "alert" : "check", "lg") + "<div></div>";
node.querySelector("div").textContent = message;
node.querySelector("div").textContent = text;
host.appendChild(node);
const ttl = kind === "bad" ? 7000 : 4000;
setTimeout(() => {
+95 -1
View File
@@ -5,7 +5,7 @@
--bg: #0a0a0c;
--ink: #f0f0f2;
--muted: #9a9aa3;
--faint: #6b6b75;
--faint: #8d8d97;
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14);
--card: #141418;
@@ -484,6 +484,7 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; }
.icon-btn.done-btn { color: var(--st-printing); border-color: rgba(61, 206, 124, 0.35); border-radius: 99px !important; }
.icon-btn.done-btn:hover { color: var(--st-printing); background: rgba(61, 206, 124, 0.12); }
.icon-btn.done-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.66rem; font-weight: 700; background: rgba(61, 206, 124, 0.18); }
.icon-btn.confirm-armed { width: auto; padding: 0 0.5rem !important; font-size: 0.7rem; font-weight: 700; }
.queue-tools { display: flex; gap: 0.1rem; flex-wrap: nowrap; flex: none; }
.send-progress { margin-top: 0.75rem; display: grid; gap: 0.55rem; }
.send-job { display: grid; gap: 0.3rem; }
@@ -1959,6 +1960,31 @@ body.desk-on main {
.tabs button:hover { color: var(--ink); filter: none; }
.tabs button.on { color: var(--ink); border-bottom-color: var(--accent); }
.conn-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 120;
padding: 0.45rem 1rem;
text-align: center;
font-size: var(--t-sm);
font-weight: 500;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.conn-banner[hidden] { display: none !important; }
.conn-banner.bad {
background: rgba(127, 29, 29, 0.92);
color: #fecaca;
border-bottom: 1px solid rgba(248, 113, 113, 0.45);
}
.conn-banner.ok {
background: rgba(20, 83, 45, 0.92);
color: #bbf7d0;
border-bottom: 1px solid rgba(61, 206, 124, 0.45);
}
.toast-host {
position: fixed;
right: 1rem;
@@ -2544,3 +2570,71 @@ ul.plain li:last-child { border-bottom: 0; }
@media (min-width: 800px) {
.grid-2 { grid-template-columns: 1fr 1fr; }
}
/* Abräumen-Dialog + Mobile „Mehr“ */
.abraeumen-dlg,
.mobile-more-sheet {
border: 1px solid var(--line-strong);
border-radius: var(--radius);
padding: 1rem 1.1rem;
background: var(--card);
color: var(--ink);
max-width: min(22rem, calc(100vw - 2rem));
box-shadow: var(--shadow);
}
.abraeumen-dlg::backdrop,
.mobile-more-sheet::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.abraeumen-form h2 { margin: 0 0 0.35rem; font-size: var(--t-lg); }
.abraeumen-form .actions { margin-top: 0.85rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
.mobile-more-sheet nav {
display: grid;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.mobile-more-sheet a {
display: block;
padding: 0.65rem 0.75rem;
border-radius: var(--radius-sm);
color: var(--ink);
text-decoration: none;
background: var(--card-2);
border: 1px solid var(--line);
}
.mobile-nav .mobile-more-btn {
appearance: none;
background: transparent;
border: 0;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.35rem 0.5rem;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (pointer: coarse) {
.icon-btn, .tiny-btn, .fchip, button, .mobile-nav a, .mobile-nav button {
min-height: 2.75rem;
min-width: 2.75rem;
}
}
@media print {
.sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; }
.app-shell { display: block; }
.app-main { margin: 0; }
body { background: #fff; color: #111; }
.card, article { box-shadow: none; border-color: #ccc; }
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Grotti",
"short_name": "Grotti",
"description": "3D-Druck-Farm steuern",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0c",
"theme_color": "#0a0a0c",
"lang": "de",
"icons": [
{
"src": "/static/brand/favicon.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/brand/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
/* 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"];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/")))
);
});
+208
View File
@@ -0,0 +1,208 @@
/** Live-UI: Verbindungsbanner, Poller, gemeinsame Zustands-Labels. */
const RUN_STATES = {
completed: "fertig",
cancelled: "abgebrochen",
error: "Fehler",
in_progress: "druckt",
printing: "druckt",
paused: "pausiert",
interrupted: "unterbrochen",
standby: "bereit",
heating: "heizt",
complete: "fertig",
offline: "offline",
waiting: "wartet",
running: "läuft",
};
let connBannerOk = true;
let connBannerTimer = 0;
function setConnectionBanner(ok, text) {
let el = document.getElementById("conn-banner");
if (!el) {
el = document.createElement("div");
el.id = "conn-banner";
el.className = "conn-banner";
el.setAttribute("role", "status");
el.hidden = true;
document.body.prepend(el);
}
clearTimeout(connBannerTimer);
if (ok) {
const wasBad = !connBannerOk;
connBannerOk = true;
if (wasBad && text) {
el.textContent = text;
el.className = "conn-banner ok";
el.hidden = false;
connBannerTimer = setTimeout(() => {
el.hidden = true;
el.textContent = "";
}, 2800);
return;
}
el.hidden = true;
el.textContent = "";
el.className = "conn-banner";
return;
}
connBannerOk = false;
el.textContent = text || "Keine Verbindung zum Server.";
el.className = "conn-banner bad";
el.hidden = false;
}
/**
* @param {() => void | Promise<void>} fn
* @param {number} intervalMs
* @returns {{ start: () => void, stop: () => void }}
*/
function createPoller(fn, intervalMs) {
const base = Math.max(1000, Number(intervalMs) || 5000);
const maxMs = 60000;
let timer = 0;
let busy = false;
let delay = base;
let fails = 0;
let running = false;
function schedule() {
if (!running) return;
clearTimeout(timer);
timer = setTimeout(tick, delay);
}
async function tick() {
if (!running || document.hidden || busy) {
schedule();
return;
}
busy = true;
try {
await fn();
delay = base;
fails = 0;
if (!connBannerOk) setConnectionBanner(true, "Wieder verbunden.");
else setConnectionBanner(true, "");
} catch (error) {
fails += 1;
delay = Math.min(maxMs, Math.max(base, delay * 2));
if (fails >= 2) {
setConnectionBanner(false, (error && error.message) || "Daten konnten nicht geladen werden.");
}
} finally {
busy = false;
schedule();
}
}
function onVis() {
if (!document.hidden && running) tick();
}
return {
start() {
if (running) return;
running = true;
document.addEventListener("visibilitychange", onVis);
tick();
},
stop() {
running = false;
document.removeEventListener("visibilitychange", onVis);
clearTimeout(timer);
timer = 0;
},
};
}
const VERDICT_REASON_LABELS = {
haftung: "Haftung",
warping: "Warping",
spaghetti: "Spaghetti",
layer_shift: "Layer-Shift",
verstopfung: "Verstopfung",
farbe: "Farbe",
sonstiges: "Sonstiges",
};
/**
* Dialog vor „Platte geräumt“: Urteil, optional Grund und Stückzahlen.
* @returns {Promise<object|null>} Payload für /abraeumen oder null bei Abbruch
*/
function promptAbraeumen(hostLabel) {
return new Promise((resolve) => {
const existing = document.getElementById("abraeumen-dlg");
if (existing) existing.remove();
const dlg = document.createElement("dialog");
dlg.id = "abraeumen-dlg";
dlg.className = "abraeumen-dlg";
const reasons = Object.entries(VERDICT_REASON_LABELS)
.map(([k, label]) => `<option value="${k}">${label}</option>`)
.join("");
dlg.innerHTML = `
<form method="dialog" class="abraeumen-form">
<h2>Platte geräumt</h2>
<p class="tiny">${escapeHtml(hostLabel || "Drucker")}</p>
<div class="field"><label for="ab-verdict">Urteil</label>
<select id="ab-verdict" name="verdict">
<option value="gut">gut</option>
<option value="ausschuss">Ausschuss</option>
</select>
</div>
<div class="field" id="ab-reason-wrap" hidden>
<label for="ab-reason">Grund</label>
<select id="ab-reason">${reasons}</select>
</div>
<div class="grid-2" id="ab-pieces" hidden>
<div class="field"><label for="ab-ok">Stück gut</label><input id="ab-ok" type="number" min="0" step="1" value="0"></div>
<div class="field"><label for="ab-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Quittieren</button>
<button type="button" class="ghost" id="ab-cancel">Abbrechen</button>
</div>
</form>`;
document.body.appendChild(dlg);
const verdict = dlg.querySelector("#ab-verdict");
const reasonWrap = dlg.querySelector("#ab-reason-wrap");
const pieces = dlg.querySelector("#ab-pieces");
function sync() {
const scrap = verdict.value === "ausschuss";
reasonWrap.hidden = !scrap;
pieces.hidden = !scrap;
}
verdict.addEventListener("change", sync);
sync();
dlg.querySelector("#ab-cancel").addEventListener("click", () => {
dlg.close();
dlg.remove();
resolve(null);
});
dlg.addEventListener("close", () => {
if (dlg.returnValue !== "ok") {
dlg.remove();
resolve(null);
return;
}
const payload = {
confirm: true,
verdict: verdict.value,
verdict_reason: verdict.value === "ausschuss" ? dlg.querySelector("#ab-reason").value : "",
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
};
dlg.remove();
resolve(payload);
});
dlg.showModal();
});
}
window.RUN_STATES = RUN_STATES;
window.setConnectionBanner = setConnectionBanner;
window.createPoller = createPoller;
window.promptAbraeumen = promptAbraeumen;
window.VERDICT_REASON_LABELS = VERDICT_REASON_LABELS;
+320
View File
@@ -0,0 +1,320 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Aufträge</h1>
<p class="tiny">Kunden, Angebote, Positionen und Fortschritt.</p>
</div>
<div class="actions">
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
</div>
</div>
<div class="split">
<section class="card">
<div class="field inline">
<label for="filter-status">Status</label>
<select id="filter-status">
<option value="">alle</option>
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Titel</th><th>Status</th><th>Fortschritt</th></tr></thead>
<tbody id="order-rows"></tbody>
</table>
</div>
</section>
<section class="card">
<h2 id="order-title">Neuer Auftrag</h2>
<form id="order-form">
<input id="order-id" type="hidden">
<div class="field"><label for="order-title-in">Titel</label><input id="order-title-in" required></div>
<div class="grid-2">
<div class="field"><label for="order-status">Status</label>
<select id="order-status">
<option value="idee">Idee</option>
<option value="angebot">Angebot</option>
<option value="bestaetigt">bestätigt</option>
<option value="druckt">druckt</option>
<option value="fertig">fertig</option>
<option value="geliefert">geliefert</option>
<option value="bezahlt">bezahlt</option>
</select>
</div>
<div class="field"><label for="order-due">Fällig</label><input id="order-due" type="date"></div>
</div>
<div class="field"><label for="order-customer">Kunde</label>
<select id="order-customer"><option value="">— keiner —</option></select>
</div>
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
<div class="actions">
<button type="submit">Merken</button>
<button type="button" class="ghost" id="order-new">Neu</button>
<button type="button" class="warn" id="order-delete" hidden>Löschen</button>
</div>
</form>
<p id="order-note" class="tiny"></p>
<p class="tiny" id="order-progress"></p>
<p class="tiny" id="order-actuals"></p>
<h2 class="section-gap">Positionen</h2>
<div class="table-wrap">
<table>
<thead><tr><th>Datei</th><th class="num">Stk</th><th class="num">fertig</th><th></th></tr></thead>
<tbody id="item-rows"></tbody>
</table>
</div>
<form id="item-form" class="section-gap">
<div class="grid-2">
<div class="field"><label for="item-file">Datei</label><select id="item-file"><option value="">—</option></select></div>
<div class="field"><label for="item-qty">Stück</label><input id="item-qty" type="number" min="1" step="1" value="1"></div>
<div class="field"><label for="item-price">Preis/Stk €</label><input id="item-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="item-material">Material</label><input id="item-material"></div>
</div>
<div class="actions"><button type="submit">Position hinzufügen</button></div>
</form>
<details class="section-gap">
<summary>Kunde anlegen</summary>
<form id="customer-form">
<div class="field"><label for="cust-name">Name</label><input id="cust-name" required></div>
<div class="field"><label for="cust-email">E-Mail</label><input id="cust-email" type="email"></div>
<button type="submit">Kunde speichern</button>
</form>
</details>
</section>
</div>
{% endblock %}
{% block script %}
<script>
const STATUS_LABEL = {
idee: "Idee", angebot: "Angebot", bestaetigt: "bestätigt", druckt: "druckt",
fertig: "fertig", geliefert: "geliefert", bezahlt: "bezahlt",
};
let orders = [];
let customers = [];
let files = [];
let current = null;
function fillCustomers() {
const sel = document.getElementById("order-customer");
const val = sel.value;
sel.innerHTML = '<option value="">— keiner —</option>';
customers.forEach((c) => {
const o = document.createElement("option");
o.value = c.id;
o.textContent = c.name;
sel.appendChild(o);
});
if (val) sel.value = val;
}
function fillFiles() {
const sel = document.getElementById("item-file");
sel.innerHTML = '<option value="">—</option>';
files.slice(0, 400).forEach((f) => {
const o = document.createElement("option");
o.value = f.id;
o.textContent = f.name;
sel.appendChild(o);
});
}
function paintActuals(order) {
const el = document.getElementById("order-actuals");
const a = order.actuals;
if (!a) { el.textContent = ""; return; }
el.textContent = "Ist: " + euro(a.cost_total) + " · " + (hours(a.duration_s) || "0 min") + " · Ausschuss " + a.pieces_bad + " Stk";
}
function fillOrder(order) {
current = order;
document.getElementById("order-id").value = order ? order.id : "";
document.getElementById("order-title-in").value = order ? order.title : "";
document.getElementById("order-status").value = order ? order.status : "idee";
document.getElementById("order-due").value = order && order.due_at ? order.due_at.slice(0, 10) : "";
document.getElementById("order-customer").value = order && order.customer_id ? order.customer_id : "";
document.getElementById("order-notes").value = order ? order.notes : "";
document.getElementById("order-title").textContent = order ? order.title : "Neuer Auftrag";
document.getElementById("order-delete").hidden = !order;
const link = document.getElementById("lieferschein-link");
if (order) {
link.href = "/auftraege/" + order.id + "/lieferschein";
link.hidden = false;
} else {
link.hidden = true;
}
const prog = order && order.progress;
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
paintActuals(order || {});
renderItems(order && order.items ? order.items : []);
}
function renderItems(items) {
const body = document.getElementById("item-rows");
body.innerHTML = "";
if (!items.length) {
body.innerHTML = '<tr><td colspan="4" class="empty">Keine Positionen.</td></tr>';
return;
}
items.forEach((item) => {
const tr = document.createElement("tr");
tr.innerHTML = "<td></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"actions\"></td>";
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
tr.children[1].textContent = item.qty;
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "ghost tiny-btn";
btn.textContent = "Einreihen";
btn.disabled = !item.file_id || !current;
btn.addEventListener("click", () => einreihen(item.id));
tr.children[3].appendChild(btn);
body.appendChild(tr);
});
}
async function einreihen(itemId) {
if (!current) return;
try {
await api("/auftraege/" + current.id + "/einreihen", {
method: "POST",
json: { item_id: itemId, when_free: true, confirm: true },
});
toast("In Warteschlange.");
const full = await api("/auftraege/" + current.id);
fillOrder(full);
await loadList();
} catch (error) {
showNote("order-note", error);
}
}
function renderList() {
const body = document.getElementById("order-rows");
body.innerHTML = "";
if (!orders.length) {
body.innerHTML = '<tr><td colspan="3" class="empty">Noch kein Auftrag.</td></tr>';
return;
}
orders.forEach((order) => {
const tr = document.createElement("tr");
tr.className = "pick";
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);
});
body.appendChild(tr);
});
}
async function loadList() {
const st = document.getElementById("filter-status").value;
const q = st ? "?status=" + encodeURIComponent(st) : "";
orders = await api("/auftraege" + q);
renderList();
}
async function loadBase() {
[customers, files] = await Promise.all([
api("/kunden").catch(() => []),
api("/dateien").catch(() => []),
]);
fillCustomers();
fillFiles();
await loadList();
fillOrder(null);
}
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
document.getElementById("order-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("order-id").value;
const payload = {
title: document.getElementById("order-title-in").value,
status: document.getElementById("order-status").value,
notes: document.getElementById("order-notes").value,
due_at: document.getElementById("order-due").value || null,
customer_id: document.getElementById("order-customer").value || null,
};
try {
const saved = id
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
: await api("/auftraege", { method: "POST", json: payload });
showNote("order-note", null);
document.getElementById("order-note").textContent = "Gemerkt.";
await loadList();
fillOrder(saved);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("order-new").addEventListener("click", () => fillOrder(null));
document.getElementById("order-delete").addEventListener("click", (event) => {
const id = document.getElementById("order-id").value;
if (!id) return;
armButton(event.currentTarget, "Auftrag wirklich löschen?", async () => {
await api("/auftraege/" + id, { method: "DELETE" });
toast("Gelöscht.");
fillOrder(null);
loadList();
});
});
document.getElementById("item-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!current) {
showNote("order-note", new Error("Zuerst Auftrag speichern."));
return;
}
const payload = {
file_id: document.getElementById("item-file").value || null,
qty: Number(document.getElementById("item-qty").value),
unit_price: Number(document.getElementById("item-price").value),
material_type: document.getElementById("item-material").value,
};
try {
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
const full = await api("/auftraege/" + current.id);
fillOrder(full);
} catch (error) {
showNote("order-note", error);
}
});
document.getElementById("customer-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const row = await api("/kunden", {
method: "POST",
json: {
name: document.getElementById("cust-name").value,
email: document.getElementById("cust-email").value,
},
});
customers.push(row);
fillCustomers();
document.getElementById("order-customer").value = row.id;
document.getElementById("cust-name").value = "";
document.getElementById("cust-email").value = "";
toast("Kunde angelegt.");
} catch (error) {
showNote("order-note", error);
}
});
loadBase().catch((error) => showNote("order-note", error));
</script>
{% endblock %}
+84 -1
View File
@@ -126,6 +126,38 @@
<p id="tg-note"></p>
</section>
<section class="card admin-only">
<h2>ntfy</h2>
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="ntfy-server">Server</label>
<input id="ntfy-server" placeholder="https://ntfy.sh" autocomplete="off">
</div>
<div class="field">
<label for="ntfy-topic">Topic</label>
<input id="ntfy-topic" placeholder="grotti-werkstatt" autocomplete="off">
</div>
<div class="field" style="grid-column:1/-1">
<label for="ntfy-token">Token (optional)</label>
<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 id="ntfy-note"></p>
</section>
<section class="card admin-only">
<h2>Backup</h2>
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
<div class="actions">
<button type="button" id="backup-now">Backup jetzt</button>
</div>
<ul id="backup-list" class="tiny"></ul>
<p id="backup-note"></p>
</section>
<section class="card admin-only">
<h2>Energie</h2>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
@@ -381,6 +413,14 @@ async function load() {
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const ntfy = settings.ntfy || {};
document.getElementById("ntfy-enabled").checked = !!ntfy.enabled;
document.getElementById("ntfy-server").value = ntfy.server || "https://ntfy.sh";
document.getElementById("ntfy-topic").value = ntfy.topic || "";
document.getElementById("ntfy-token").value = "";
document.getElementById("ntfy-token").placeholder = ntfy.token_set
? ("gesetzt: " + (ntfy.token || "••••"))
: "optional";
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
@@ -394,7 +434,7 @@ async function load() {
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens()]);
await Promise.all([loadUsers(), loadTokens(), loadBackups()]);
}
document.getElementById("user-add")?.addEventListener("submit", async (event) => {
@@ -542,6 +582,49 @@ document.getElementById("tg-save").addEventListener("click", async () => {
}
});
document.getElementById("ntfy-save")?.addEventListener("click", async () => {
const token = document.getElementById("ntfy-token").value.trim();
const payload = {
ntfy: {
enabled: document.getElementById("ntfy-enabled").checked,
server: document.getElementById("ntfy-server").value.trim() || "https://ntfy.sh",
topic: document.getElementById("ntfy-topic").value.trim(),
},
};
if (token) payload.ntfy.token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("ntfy-note", null, "ntfy gespeichert.");
load();
} catch (error) {
showNote("ntfy-note", error);
}
});
async function loadBackups() {
const list = document.getElementById("backup-list");
if (!list) return;
try {
const out = await api("/backup");
list.innerHTML = (out.items || []).slice(0, 8).map((item) => {
const mb = ((item.size || 0) / (1024 * 1024)).toLocaleString("de-DE", { maximumFractionDigits: 1 });
return `<li><a href="/backup/${encodeURIComponent(item.name)}" download>${escapeHtml(item.name)}</a> · ${mb} MB</li>`;
}).join("") || "<li>Noch kein Backup.</li>";
} catch (error) {
list.innerHTML = "<li>Liste nicht verfügbar.</li>";
}
}
document.getElementById("backup-now")?.addEventListener("click", async () => {
try {
const out = await api("/backup", { method: "POST", json: {} });
showNote("backup-note", null, "Backup erstellt: " + (out.name || ""));
loadBackups();
} catch (error) {
showNote("backup-note", error);
}
});
document.getElementById("tg-test").addEventListener("click", async () => {
try {
await api("/telegram/test", { method: "POST", json: {} });
+87 -18
View File
@@ -9,7 +9,9 @@
<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=52">
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=54">
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
<meta name="theme-color" content="#0a0a0c">
<script>
(function () {
try {
@@ -38,6 +40,7 @@
</script>
</head>
<body class="app-shell">
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
@@ -96,14 +99,15 @@
</div>
<nav class="nav-main" aria-label="Hauptmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-activity"/></svg><span>Statistik</span></a>
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-grid"/></svg><span>Statistik</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-nozzle"/></svg><span>Wartung</span></a>
</nav>
<div class="live-panel" id="live-panel">
<details class="queue-box" data-box="run" id="run-box" open hidden>
@@ -131,7 +135,6 @@
</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>
<a href="/ideen" class="{{ 'on' if page == 'ideen' else '' }}" title="Ideen"><svg class="ico"><use href="#i-bulb"/></svg><span>Ideen</span></a>
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
</nav>
<div class="side-foot">
@@ -152,12 +155,26 @@
</div>
<nav class="mobile-nav" aria-label="Schnellmenü">
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk"><svg class="ico"><use href="#i-send"/></svg><span>Dateien</span></a>
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }} nav-shelf"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }} nav-shelf"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Queue</span></a>
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }}"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
<button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
</nav>
<script src="{{ url_for('static', filename='api.js') }}?v=26"></script>
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
<nav aria-label="Weitere Seiten">
<a href="/kameras">Kameras</a>
<a href="/auftraege">Aufträge</a>
<a href="/protokoll">Protokoll</a>
<a href="/statistik">Statistik</a>
<a href="/material">Material</a>
<a href="/wartung">Wartung</a>
<a href="/einstellungen">Einstellungen</a>
<a href="/doku">Doku</a>
</nav>
<form method="dialog"><button class="ghost">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>
document.getElementById("logout-btn")?.addEventListener("click", async () => {
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
@@ -476,17 +493,27 @@ async function refreshTasks() {
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
</li>`).join("");
list.querySelectorAll("[data-cleared]").forEach((btn) => {
btn.onclick = () => armButton(btn, "leer?", async () => {
btn.onclick = async () => {
const host = btn.dataset.cleared;
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
if (!payload) return;
btn.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(btn.dataset.cleared) + "/abraeumen", { method: "POST", json: { confirm: true } });
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
toast("Platte frei.");
} catch (error) { toast(error.message, "bad"); }
refreshTasks();
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
});
btn.disabled = false;
};
});
}
document.getElementById("mobile-more")?.addEventListener("click", () => {
document.getElementById("mobile-more-sheet")?.showModal();
});
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/static/sw.js").catch(() => {});
}
document.getElementById("events-clear").addEventListener("click", async (event) => {
event.preventDefault();
@@ -502,10 +529,52 @@ refreshPrintQueue();
refreshEvents();
refreshTasks();
window.refreshTasks = refreshTasks;
setInterval(refreshTasks, 8000);
setInterval(refreshSendQueue, 2000);
setInterval(refreshPrintQueue, 4000);
setInterval(refreshEvents, 8000);
(function liveUpdates() {
const sseKinds = {
queue: () => refreshPrintQueue(),
senden: () => refreshSendQueue(),
ereignis: () => refreshEvents(),
aufgaben: () => refreshTasks(),
drucker: () => {
refreshPrintQueue();
refreshTasks();
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
},
};
if (typeof EventSource !== "undefined") {
let es;
let retryMs = 2000;
const retryMax = 60000;
let hadStream = false;
function connectStream() {
es = new EventSource("/api/v1/stream", { withCredentials: true });
es.addEventListener("open", () => {
retryMs = 2000;
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
hadStream = true;
});
es.addEventListener("error", () => {
es.close();
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
setTimeout(connectStream, retryMs);
retryMs = Math.min(retryMax, retryMs * 2);
});
Object.keys(sseKinds).forEach((kind) => {
es.addEventListener(kind, () => sseKinds[kind]());
});
es.addEventListener("ping", () => {});
}
connectStream();
}
createPoller(refreshPrintQueue, 8000).start();
createPoller(refreshSendQueue, 4000).start();
createPoller(refreshEvents, 12000).start();
createPoller(refreshTasks, 12000).start();
})();
</script>
{% block script %}{% endblock %}
</body>
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 "Segoe UI", system-ui, sans-serif; color: #111; background: #f3f3f3; }
.toolbar { display: flex; gap: 0.6rem; padding: 0.8rem 1rem; background: #1a1a1a; color: #eee; }
.toolbar a, .toolbar button {
color: #111; background: #f97316; border: 0; border-radius: 8px;
padding: 0.4rem 0.85rem; font: inherit; font-weight: 650; text-decoration: none; cursor: pointer;
}
.toolbar a.ghost { background: transparent; color: #eee; border: 1px solid #555; }
.sheet {
max-width: 210mm; margin: 1.2rem auto; padding: 14mm;
background: #fff; border: 1px solid #ccc; border-radius: 4px;
}
h1 { margin: 0 0 4mm; font-size: 18px; }
.meta { margin-bottom: 8mm; color: #333; font-size: 13px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border-bottom: 1px solid #ddd; padding: 3mm 2mm; text-align: left; }
th.num, td.num { text-align: right; font-variant-numeric: tabular-nums; }
@media print {
body { background: #fff; }
.toolbar { display: none; }
.sheet { margin: 0; border: 0; border-radius: 0; }
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" onclick="window.print()">Drucken</button>
<a class="ghost" href="/auftraege">Zurück</a>
</div>
<article class="sheet">
<h1>Lieferschein — Auftrag #{{ order.id }}</h1>
<div class="meta">
{% if order.customer %}<p><strong>Kunde:</strong> {{ order.customer.name }}{% if order.customer.email %} · {{ order.customer.email }}{% endif %}</p>{% endif %}
<p><strong>Titel:</strong> {{ order.title }}</p>
{% if order.due_at %}<p><strong>Fällig:</strong> {{ order.due_at }}</p>{% endif %}
<p><strong>Status:</strong> {{ order.status }}</p>
{% if order.notes %}<p>{{ order.notes }}</p>{% endif %}
</div>
<table>
<thead>
<tr><th>Position</th><th class="num">Menge</th><th class="num">geliefert</th><th class="num">€/Stk</th></tr>
</thead>
<tbody>
{% for item in order.items %}
<tr>
<td>{{ item.file_name or item.note or ('Datei #' ~ item.file_id) }}</td>
<td class="num">{{ item.qty }}</td>
<td class="num">{{ item.done_ok }}</td>
<td class="num">{{ '%.2f'|format(item.unit_price) }}</td>
</tr>
{% else %}
<tr><td colspan="4">Keine Positionen.</td></tr>
{% endfor %}
</tbody>
</table>
{% if order.progress %}
<p class="meta" style="margin-top:8mm">Fortschritt gesamt: {{ order.progress.label }}</p>
{% endif %}
</article>
</body>
</html>
+13
View File
@@ -130,6 +130,12 @@ function render(data) {
<td class="num">${p.completed}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="6">Keine Daten.</td></tr>';
const reasons = (data.scrap_reasons || []).map((r) => `
<tr>
<td>${escapeHtml(r.reason || "—")}</td>
<td class="num">${r.count}</td>
</tr>`).join("") || '<tr><td class="empty" colspan="2">Kein Ausschuss im Zeitraum.</td></tr>';
const materials = (data.by_material || []).map((m) => `
<tr>
<td>${escapeHtml(m.material_type || "—")}</td>
@@ -156,6 +162,13 @@ function render(data) {
<tbody>${printers}</tbody>
</table></div>
</div>
<div class="card">
<h2>Ausschuss-Gründe</h2>
<div class="table-wrap"><table>
<thead><tr><th>Grund</th><th>Anzahl</th></tr></thead>
<tbody>${reasons}</tbody>
</table></div>
</div>
<div class="card">
<h2>Nach Material</h2>
<div class="table-wrap"><table>
+5 -3
View File
@@ -1562,19 +1562,21 @@ async function handleWallClick(event) {
return;
}
if (button.dataset.cmd === "abraeumen") {
armButton(button, "Platte frei?", async () => {
(async () => {
const payload = await promptAbraeumen(host);
if (!payload) return;
button.disabled = true;
try {
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", {
method: "POST",
json: { confirm: true },
json: payload,
});
toast("Platte quittiert — der nächste Job darf starten.");
refresh();
if (window.refreshTasks) window.refreshTasks();
} catch (error) { toast(error.message, "bad"); }
finally { button.disabled = false; }
});
})();
return;
}
button.disabled = true;
+93
View File
@@ -0,0 +1,93 @@
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Wartung</h1>
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
</div>
</div>
<div id="wartung-root"></div>
<p id="wartung-note"></p>
{% endblock %}
{% block script %}
<script>
function partRow(part) {
const overdue = part.overdue_hours || part.overdue_meters;
return `<tr class="${overdue ? "warn" : ""}">
<td>${escapeHtml(part.label || part.part_key)}</td>
<td class="num">${(part.hours_since || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h</td>
<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>
</tr>`;
}
function printerCard(block) {
const c = block.counters || {};
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>
<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>
<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>
</section>`;
}
async function load() {
const data = await api("/wartung");
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|| '<p class="empty">Keine Drucker angelegt.</p>';
}
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
const form = event.target.closest("form.wartung-add");
if (!form) return;
event.preventDefault();
const fd = new FormData(form);
try {
await api("/wartung/teile", {
method: "POST",
json: {
printer_host: form.dataset.host,
part_key: fd.get("part_key"),
label: fd.get("label"),
life_hours: Number(fd.get("life_hours") || 0),
life_meters: Number(fd.get("life_meters") || 0),
note: fd.get("note"),
},
});
form.reset();
showNote("wartung-note", null, "Teil gespeichert.");
load();
} catch (error) {
showNote("wartung-note", error);
}
});
document.getElementById("wartung-root").addEventListener("click", async (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);
}
});
load();
</script>
{% endblock %}