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:
1 parent
0236278929
commit
8e88efc094
45 files changed
+3121
-122
No files matched your search
@@ -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 %}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user