Grotti 3.6.0: Lauf-Identität nach Filament-Pause, robustere Queue, Projekte/Aufträge list-first und API-Polish.
This commit is contained in:
1 parent
d1eb87ba25
commit
2b1e36634a
53 files changed
+4254
-636
No files matched your search
@@ -14,5 +14,5 @@
|
||||
<section class="queue-box" data-box="alerts" id="alert-box" hidden>
|
||||
<h2 class="queue-title"><svg class="ico title-ico"><use href="#i-bell"/></svg><span class="grow">Meldungen</span><span class="count bad" id="alert-count">0</span></h2>
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">Meldungen gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</section>
|
||||
@@ -19,7 +19,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
|
||||
<div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
|
||||
<div class="toolbar" aria-label="Drucker">
|
||||
<label class="field inline" for="f-host">
|
||||
<span class="sr-only">Drucker</span>
|
||||
<select id="f-host" title="Drucker">
|
||||
<option value="">Alle Drucker</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p id="tl-note" class="tiny"></p>
|
||||
<div class="timeline" id="timeline">
|
||||
@@ -67,11 +74,13 @@ function renderFilters() {
|
||||
KINDS.map(([k, label]) =>
|
||||
`<button type="button" class="fchip ${filter.kinds.has(k) ? "on" : ""}" data-kind="${k}"><span class="tl-dot ${k}"></span>${label}</button>`
|
||||
).join("");
|
||||
document.getElementById("f-host").innerHTML =
|
||||
`<button type="button" class="fchip ${filter.host ? "" : "on"}" data-host="">Alle Drucker</button>` +
|
||||
const hostSel = document.getElementById("f-host");
|
||||
const cur = filter.host;
|
||||
hostSel.innerHTML = '<option value="">Alle Drucker</option>' +
|
||||
printers.map((p) =>
|
||||
`<button type="button" class="fchip ${filter.host === p.host ? "on" : ""}" data-host="${escapeHtml(p.host)}">${escapeHtml(p.name)}</button>`
|
||||
`<option value="${escapeHtml(p.host)}">${escapeHtml(p.name)}</option>`
|
||||
).join("");
|
||||
if ([...hostSel.options].some((o) => o.value === cur)) hostSel.value = cur;
|
||||
}
|
||||
|
||||
function sinceIso() {
|
||||
@@ -165,8 +174,6 @@ document.addEventListener("click", (e) => {
|
||||
if (!k) filter.kinds.clear();
|
||||
else if (filter.kinds.has(k)) filter.kinds.delete(k);
|
||||
else filter.kinds.add(k);
|
||||
} else if (e.target.closest("#f-host [data-host]")) {
|
||||
filter.host = e.target.closest("[data-host]").dataset.host;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +182,13 @@ document.addEventListener("click", (e) => {
|
||||
load();
|
||||
});
|
||||
|
||||
document.getElementById("f-host").addEventListener("change", () => {
|
||||
filter.host = document.getElementById("f-host").value || "";
|
||||
syncUrl();
|
||||
renderFilters();
|
||||
load();
|
||||
});
|
||||
|
||||
document.getElementById("tl-refresh").addEventListener("click", load);
|
||||
document.getElementById("tl-read").addEventListener("click", async () => {
|
||||
await api("/ereignisse/gelesen", { method: "POST", json: {} });
|
||||
|
||||
+226
-85
@@ -7,9 +7,10 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a class="ghost" id="lieferschein-link" href="#" hidden target="_blank" rel="noopener">Lieferschein</a>
|
||||
<button type="button" class="btn-primary" id="order-new-head"><svg class="ico"><use href="#i-plus"/></svg>Neuer Auftrag</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="split edit-split is-idle" id="order-layout">
|
||||
<section class="card">
|
||||
<div class="field inline">
|
||||
<label for="filter-status">Status</label>
|
||||
@@ -32,9 +33,14 @@
|
||||
<div class="pager" id="order-pager" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2 id="order-title">Neuer Auftrag</h2>
|
||||
<form id="order-form">
|
||||
<section class="card edit-panel" id="order-panel" hidden>
|
||||
<div class="mat-form-head">
|
||||
<h2 id="order-title">Neuer Auftrag</h2>
|
||||
<button type="button" class="ghost tiny-btn" id="order-edit-btn" hidden>Bearbeiten</button>
|
||||
<button type="button" class="icon-btn" id="order-close" title="Schließen" aria-label="Schließen"></button>
|
||||
</div>
|
||||
|
||||
<form id="order-form" hidden>
|
||||
<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">
|
||||
@@ -57,43 +63,61 @@
|
||||
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="ghost" id="order-new">Neu</button>
|
||||
<button type="button" class="ghost" id="order-cancel-edit" hidden>Abbrechen</button>
|
||||
<button type="button" class="btn-danger" 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 id="order-detail" hidden>
|
||||
<p class="tiny" id="order-progress"></p>
|
||||
<p class="tiny" id="order-actuals"></p>
|
||||
<p class="tiny" id="order-notes-view"></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>
|
||||
<div class="actions"><button type="submit">Position hinzufügen</button></div>
|
||||
</form>
|
||||
<details class="section-gap">
|
||||
<summary>Kunde anlegen / bearbeiten</summary>
|
||||
<form id="customer-form">
|
||||
<input type="hidden" id="cust-id">
|
||||
<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>
|
||||
|
||||
<form id="item-edit-form" class="section-gap card" hidden style="padding:0.75rem;margin-top:0.75rem">
|
||||
<input type="hidden" id="item-edit-id">
|
||||
<h3 style="margin:0 0 0.5rem;font-size:0.95rem">Position ändern</h3>
|
||||
<div class="grid-2">
|
||||
<div class="field"><label for="item-edit-qty">Stück</label><input id="item-edit-qty" type="number" min="1" step="1" value="1"></div>
|
||||
<div class="field"><label for="item-edit-price">Preis/Stk €</label><input id="item-edit-price" type="number" min="0" step="0.01" value="0"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Kunde speichern</button>
|
||||
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
|
||||
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<button type="button" class="ghost" id="item-edit-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<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 / bearbeiten</summary>
|
||||
<form id="customer-form">
|
||||
<input type="hidden" id="cust-id">
|
||||
<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>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Kunde speichern</button>
|
||||
<button type="button" class="ghost" id="cust-edit-current">Aktuellen laden</button>
|
||||
<button type="button" class="btn-danger" id="cust-delete" hidden>Kunde löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -107,6 +131,43 @@ let orders = [];
|
||||
let customers = [];
|
||||
let files = [];
|
||||
let current = null;
|
||||
/** idle | create | view | edit */
|
||||
let panelMode = "idle";
|
||||
let orderPage = 1;
|
||||
|
||||
document.getElementById("order-close").innerHTML = icon("x");
|
||||
|
||||
function openPanel() {
|
||||
document.getElementById("order-panel").hidden = false;
|
||||
document.getElementById("order-layout").classList.remove("is-idle");
|
||||
document.getElementById("order-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelMode = "idle";
|
||||
current = null;
|
||||
document.getElementById("order-panel").hidden = true;
|
||||
document.getElementById("order-layout").classList.add("is-idle");
|
||||
document.getElementById("order-form").hidden = true;
|
||||
document.getElementById("order-detail").hidden = true;
|
||||
document.getElementById("order-edit-btn").hidden = true;
|
||||
document.getElementById("order-cancel-edit").hidden = true;
|
||||
document.getElementById("order-delete").hidden = true;
|
||||
document.getElementById("lieferschein-link").hidden = true;
|
||||
document.getElementById("item-edit-form").hidden = true;
|
||||
document.getElementById("order-note").textContent = "";
|
||||
history.replaceState(null, "", location.pathname);
|
||||
renderList();
|
||||
}
|
||||
|
||||
function setFormValues(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 || "") : "";
|
||||
}
|
||||
|
||||
function fillCustomers() {
|
||||
const sel = document.getElementById("order-customer");
|
||||
@@ -146,27 +207,64 @@ function paintActuals(order) {
|
||||
el.textContent = bits.join(" · ");
|
||||
}
|
||||
|
||||
function fillOrder(order) {
|
||||
function showCreate() {
|
||||
panelMode = "create";
|
||||
current = null;
|
||||
setFormValues(null);
|
||||
document.getElementById("order-title").textContent = "Neuer Auftrag";
|
||||
document.getElementById("order-form").hidden = false;
|
||||
document.getElementById("order-detail").hidden = true;
|
||||
document.getElementById("order-edit-btn").hidden = true;
|
||||
document.getElementById("order-cancel-edit").hidden = true;
|
||||
document.getElementById("order-delete").hidden = true;
|
||||
document.getElementById("lieferschein-link").hidden = true;
|
||||
document.getElementById("order-note").textContent = "";
|
||||
document.getElementById("item-edit-form").hidden = true;
|
||||
history.replaceState(null, "", location.pathname);
|
||||
openPanel();
|
||||
renderList();
|
||||
document.getElementById("order-title-in").focus();
|
||||
}
|
||||
|
||||
function showView(order) {
|
||||
panelMode = "view";
|
||||
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;
|
||||
setFormValues(order);
|
||||
document.getElementById("order-title").textContent = order.title || "Auftrag";
|
||||
document.getElementById("order-form").hidden = true;
|
||||
document.getElementById("order-detail").hidden = false;
|
||||
document.getElementById("order-edit-btn").hidden = false;
|
||||
document.getElementById("order-cancel-edit").hidden = true;
|
||||
document.getElementById("order-delete").hidden = true;
|
||||
document.getElementById("item-edit-form").hidden = true;
|
||||
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;
|
||||
link.href = "/auftraege/" + order.id + "/lieferschein";
|
||||
link.hidden = false;
|
||||
history.replaceState(null, "", "#" + order.id);
|
||||
const prog = order.progress;
|
||||
document.getElementById("order-progress").textContent = prog ? "Fortschritt: " + prog.label : "";
|
||||
paintActuals(order || {});
|
||||
renderItems(order && order.items ? order.items : []);
|
||||
paintActuals(order);
|
||||
const notes = (order.notes || "").trim();
|
||||
document.getElementById("order-notes-view").textContent = notes ? "Notiz: " + notes : "";
|
||||
renderItems(order.items || []);
|
||||
openPanel();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function showEdit(order) {
|
||||
panelMode = "edit";
|
||||
current = order;
|
||||
setFormValues(order);
|
||||
document.getElementById("order-title").textContent = "Bearbeiten";
|
||||
document.getElementById("order-form").hidden = false;
|
||||
document.getElementById("order-detail").hidden = true;
|
||||
document.getElementById("order-edit-btn").hidden = true;
|
||||
document.getElementById("order-cancel-edit").hidden = false;
|
||||
document.getElementById("order-delete").hidden = false;
|
||||
document.getElementById("item-edit-form").hidden = true;
|
||||
openPanel();
|
||||
renderList();
|
||||
document.getElementById("order-title-in").focus();
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
@@ -179,7 +277,11 @@ function renderItems(items) {
|
||||
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 || "—"));
|
||||
if (item.file_id) {
|
||||
tr.children[0].innerHTML = linkTo("datei", item.file_id, item.file_name || ("#" + item.file_id));
|
||||
} else {
|
||||
tr.children[0].textContent = item.file_name || "—";
|
||||
}
|
||||
tr.children[1].textContent = item.qty;
|
||||
tr.children[2].textContent = item.done_ok + (item.done_bad ? " (−" + item.done_bad + ")" : "");
|
||||
const tools = document.createElement("div");
|
||||
@@ -194,7 +296,7 @@ function renderItems(items) {
|
||||
edit.type = "button";
|
||||
edit.className = "ghost tiny-btn";
|
||||
edit.textContent = "Ändern";
|
||||
edit.addEventListener("click", () => editItem(item));
|
||||
edit.addEventListener("click", () => beginEditItem(item));
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "btn-danger tiny-btn";
|
||||
@@ -204,7 +306,7 @@ function renderItems(items) {
|
||||
await api("/auftraege/positionen/" + item.id, { method: "DELETE" });
|
||||
toast("Position gelöscht.");
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
fillOrder(full);
|
||||
showView(full);
|
||||
});
|
||||
});
|
||||
tools.append(btn, edit, del);
|
||||
@@ -213,22 +315,12 @@ function renderItems(items) {
|
||||
});
|
||||
}
|
||||
|
||||
async function editItem(item) {
|
||||
const qty = prompt("Stückzahl", String(item.qty || 1));
|
||||
if (qty == null) return;
|
||||
const price = prompt("Preis/Stk €", String(item.unit_price || 0));
|
||||
if (price == null) return;
|
||||
try {
|
||||
await api("/auftraege/positionen/" + item.id, {
|
||||
method: "PATCH",
|
||||
json: { qty: Number(qty) || 1, unit_price: Number(price) || 0 },
|
||||
});
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
fillOrder(full);
|
||||
toast("Position gespeichert.");
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
function beginEditItem(item) {
|
||||
document.getElementById("item-edit-id").value = item.id;
|
||||
document.getElementById("item-edit-qty").value = item.qty || 1;
|
||||
document.getElementById("item-edit-price").value = item.unit_price || 0;
|
||||
document.getElementById("item-edit-form").hidden = false;
|
||||
document.getElementById("item-edit-qty").focus();
|
||||
}
|
||||
|
||||
async function einreihen(itemId) {
|
||||
@@ -240,42 +332,40 @@ async function einreihen(itemId) {
|
||||
});
|
||||
toast("In Warteschlange.");
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
fillOrder(full);
|
||||
showView(full);
|
||||
await loadList();
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
}
|
||||
|
||||
let orderPage = 1;
|
||||
|
||||
function renderList() {
|
||||
const body = document.getElementById("order-rows");
|
||||
body.innerHTML = "";
|
||||
const pager = document.getElementById("order-pager");
|
||||
if (!orders.length) {
|
||||
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="ghost" id="order-empty-new">Ersten Auftrag anlegen →</button></p></div></td></tr>';
|
||||
document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null));
|
||||
body.innerHTML = '<tr><td colspan="3" class="empty"><div class="empty-state"><p>Noch kein Auftrag.</p><p><button type="button" class="btn-primary" id="order-empty-new">Ersten Auftrag anlegen</button></p></div></td></tr>';
|
||||
document.getElementById("order-empty-new")?.addEventListener("click", showCreate);
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
const selected = document.getElementById("order-id").value;
|
||||
const selected = current ? String(current.id) : "";
|
||||
const paged = pageSlice(orders, orderPage, DEFAULT_PAGE_SIZE);
|
||||
orderPage = paged.page;
|
||||
paged.items.forEach((order) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td>";
|
||||
tr.children[0].textContent = order.title;
|
||||
const st = order.status || "";
|
||||
const chipState = st === "druckt" ? "printing" : (st === "fertig" || st === "geliefert" || st === "bezahlt") ? "done" : st === "bestaetigt" ? "waiting" : "idle";
|
||||
tr.children[1].innerHTML = chipHtml(chipState, STATUS_LABEL[st] || st);
|
||||
tr.children[2].textContent = (order.progress && order.progress.label) || "";
|
||||
tr.children[2].title = (order.progress && order.progress.label) || "";
|
||||
tr.addEventListener("click", async () => {
|
||||
try {
|
||||
const full = await api("/auftraege/" + order.id);
|
||||
fillOrder(full);
|
||||
renderList();
|
||||
showView(full);
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
@@ -304,11 +394,28 @@ async function loadBase() {
|
||||
fillCustomers();
|
||||
fillFiles();
|
||||
await loadList();
|
||||
fillOrder(null);
|
||||
}
|
||||
|
||||
document.getElementById("filter-status").addEventListener("change", () => loadList().catch((e) => showNote("order-note", e)));
|
||||
|
||||
document.getElementById("order-new-head").addEventListener("click", showCreate);
|
||||
document.getElementById("order-close").addEventListener("click", closePanel);
|
||||
document.getElementById("order-edit-btn").addEventListener("click", () => {
|
||||
if (current) showEdit(current);
|
||||
});
|
||||
document.getElementById("order-cancel-edit").addEventListener("click", async () => {
|
||||
if (!current) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
showView(full);
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("order-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("order-id").value;
|
||||
@@ -324,22 +431,22 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
|
||||
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/auftraege", { method: "POST", json: payload });
|
||||
showNote("order-note", null);
|
||||
document.getElementById("order-note").textContent = "Gespeichert.";
|
||||
toast("Gespeichert.", "good");
|
||||
await loadList();
|
||||
fillOrder(saved);
|
||||
const full = await api("/auftraege/" + saved.id);
|
||||
showView(full);
|
||||
} 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);
|
||||
closePanel();
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
@@ -359,12 +466,36 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
|
||||
try {
|
||||
await api("/auftraege/" + current.id + "/positionen", { method: "POST", json: payload });
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
fillOrder(full);
|
||||
showView(full);
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("item-edit-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!current) return;
|
||||
const itemId = document.getElementById("item-edit-id").value;
|
||||
try {
|
||||
await api("/auftraege/positionen/" + itemId, {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
qty: Number(document.getElementById("item-edit-qty").value) || 1,
|
||||
unit_price: Number(document.getElementById("item-edit-price").value) || 0,
|
||||
},
|
||||
});
|
||||
document.getElementById("item-edit-form").hidden = true;
|
||||
const full = await api("/auftraege/" + current.id);
|
||||
showView(full);
|
||||
toast("Position gespeichert.");
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("item-edit-cancel").addEventListener("click", () => {
|
||||
document.getElementById("item-edit-form").hidden = true;
|
||||
});
|
||||
|
||||
document.getElementById("customer-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("cust-id").value;
|
||||
@@ -421,6 +552,16 @@ document.getElementById("cust-delete").addEventListener("click", (event) => {
|
||||
});
|
||||
});
|
||||
|
||||
loadBase().catch((error) => showNote("order-note", error));
|
||||
loadBase().then(async () => {
|
||||
const hash = (location.hash || "").replace(/^#/, "");
|
||||
if (hash && /^\d+$/.test(hash)) {
|
||||
try {
|
||||
const full = await api("/auftraege/" + hash);
|
||||
showView(full);
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
}
|
||||
}).catch((error) => showNote("order-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -110,9 +110,14 @@ function costBreakdownHtml(energy, filamentCost) {
|
||||
function render(data) {
|
||||
const run = data.run;
|
||||
const name = (run.filename || "").split("/").pop() || "ohne Datei";
|
||||
document.getElementById("bericht-sub").textContent =
|
||||
(run.printer_name || run.printer_host) + " · " + name
|
||||
+ (run.chargen_nr ? " · " + run.chargen_nr : "");
|
||||
const subBits = [
|
||||
run.printer_host
|
||||
? linkTo("drucker", run.printer_host, run.printer_name || run.printer_host)
|
||||
: escapeHtml(run.printer_name || ""),
|
||||
run.file_id ? linkTo("datei", run.file_id, name) : escapeHtml(name),
|
||||
run.chargen_nr ? escapeHtml(run.chargen_nr) : "",
|
||||
].filter(Boolean);
|
||||
document.getElementById("bericht-sub").innerHTML = subBits.join(" · ");
|
||||
|
||||
const planned = Number(run.weight_planned_g) || 0;
|
||||
const used = Number(run.weight_used_g) || 0;
|
||||
@@ -128,7 +133,7 @@ function render(data) {
|
||||
|
||||
const spoolRows = (data.spools || []).map((s) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
|
||||
<td>${s.spool_id ? linkTo("spule", s.spool_id, s.name) : escapeHtml(s.name)}${s.ace_slot ? " · ACE " + s.ace_slot : ""}</td>
|
||||
<td>${escapeHtml(s.material_type || "")}</td>
|
||||
<td class="num">${grams(s.weight_planned_g)}</td>
|
||||
<td class="num">${grams(s.weight_g)}${s.is_estimate ? ' <span class="tag">Schätzwert</span>' : ""}</td>
|
||||
@@ -161,7 +166,8 @@ function render(data) {
|
||||
<p>Kosten <b>${euro(cost)}</b> (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · <b>" + euro(run.cost_per_part) + "</b>/Teil" : ""}</p>
|
||||
${run.pieces_ok || run.pieces_bad ? `<p>Stück <b>${run.pieces_ok || 0}</b> gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}</p>` : ""}
|
||||
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
|
||||
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
|
||||
${data.project ? "<p class=\"tiny\">Projekt: " + linkTo("projekt", data.project.id, data.project.name) + "</p>" : ""}
|
||||
${run.order_id ? "<p class=\"tiny\">Auftrag: " + linkTo("auftrag", run.order_id, "#" + run.order_id) + "</p>" : ""}
|
||||
</div>
|
||||
${costBreakdownHtml(energy, run.filament_cost)}
|
||||
</div>
|
||||
|
||||
@@ -604,6 +604,20 @@ function folderLabel(folder) {
|
||||
return "Wurzel";
|
||||
}
|
||||
|
||||
function folderMatches(fileFolder, selected) {
|
||||
if (!selected) return true;
|
||||
if (selected === "root") return !fileFolder;
|
||||
if (!fileFolder) return false;
|
||||
return fileFolder === selected || fileFolder.startsWith(selected + "/");
|
||||
}
|
||||
|
||||
function releaseRootForProject() {
|
||||
const project = document.getElementById("file-filter-project").value;
|
||||
const folder = document.getElementById("file-filter-folder");
|
||||
if (!folder || !project || project === "none" || folder.value !== "root") return;
|
||||
folder.value = "";
|
||||
}
|
||||
|
||||
function saveLibMemory() {
|
||||
try {
|
||||
localStorage.setItem(LIB_KEY, JSON.stringify({
|
||||
@@ -650,8 +664,7 @@ function filteredFiles() {
|
||||
const types = ((file.filaments) || []).map((f) => f.type).filter(Boolean);
|
||||
if (!types.includes(material)) return false;
|
||||
}
|
||||
if (folder === "root" && folderKey(file)) return false;
|
||||
if (folder && folder !== "root" && folderKey(file) !== folder) return false;
|
||||
if (!folderMatches(folderKey(file), folder)) return false;
|
||||
if (!q) return true;
|
||||
const hay = [
|
||||
file.name || "",
|
||||
@@ -809,10 +822,13 @@ function alwaysMeta(file, project) {
|
||||
file.weight_g ? grams(file.weight_g) : "",
|
||||
layerHeightText(file),
|
||||
families[file.printer_family] || "",
|
||||
project ? project.name : "",
|
||||
];
|
||||
if (!file.weight_g && !file.estimated_s) bits.push(sizeText(file.size_bytes));
|
||||
return bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`).join("") + swatchesHtml(file);
|
||||
const spans = bits.filter(Boolean).map((t) => `<span>${escapeHtml(t)}</span>`);
|
||||
if (project) {
|
||||
spans.push(`<a class="lib-proj" href="/projekte#${encodeURIComponent(project.id)}" onclick="event.stopPropagation()">${escapeHtml(project.name)}</a>`);
|
||||
}
|
||||
return spans.join("") + swatchesHtml(file);
|
||||
}
|
||||
|
||||
function detailMeta(file) {
|
||||
@@ -1154,6 +1170,12 @@ async function load() {
|
||||
fillProjectFilter();
|
||||
fillMaterialFilter();
|
||||
fillFolderFilter();
|
||||
const deepProject = new URLSearchParams(location.search).get("project");
|
||||
if (deepProject) {
|
||||
const pf = document.getElementById("file-filter-project");
|
||||
if (pf && [...pf.options].some((o) => o.value === deepProject)) pf.value = deepProject;
|
||||
}
|
||||
releaseRootForProject();
|
||||
renderLibrary();
|
||||
renderTabs();
|
||||
refreshFlow();
|
||||
@@ -1689,6 +1711,7 @@ document.getElementById("lib-list").addEventListener("keydown", (event) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
const onChange = () => {
|
||||
if (id === "file-filter-project") releaseRootForProject();
|
||||
libPage = 1;
|
||||
saveLibMemory();
|
||||
renderLibrary();
|
||||
|
||||
@@ -257,6 +257,10 @@
|
||||
</form>
|
||||
<p id="token-note"></p>
|
||||
<p class="ok" id="token-once" hidden></p>
|
||||
<div id="token-qr-wrap" hidden style="margin-top:0.75rem">
|
||||
<p class="tiny">App-Kopplung — QR mit Server-URL und Token (nur jetzt):</p>
|
||||
<canvas id="token-qr" width="180" height="180" aria-label="QR-Code"></canvas>
|
||||
</div>
|
||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
|
||||
</section>
|
||||
|
||||
@@ -293,6 +297,7 @@
|
||||
</dialog>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||||
<script>
|
||||
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
|
||||
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
|
||||
@@ -543,7 +548,9 @@ document.getElementById("user-add")?.addEventListener("submit", async (event) =>
|
||||
document.getElementById("token-add").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const once = document.getElementById("token-once");
|
||||
const qrWrap = document.getElementById("token-qr-wrap");
|
||||
once.hidden = true;
|
||||
if (qrWrap) qrWrap.hidden = true;
|
||||
try {
|
||||
const out = await api("/tokens", {
|
||||
method: "POST",
|
||||
@@ -555,8 +562,20 @@ document.getElementById("token-add").addEventListener("submit", async (event) =>
|
||||
document.getElementById("token-add").reset();
|
||||
once.hidden = false;
|
||||
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
|
||||
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
|
||||
showNote("token-note", null, "Token erzeugt — jetzt kopieren oder QR scannen.");
|
||||
loadTokens();
|
||||
if (out.token && window.QRCode && qrWrap) {
|
||||
const canvas = document.getElementById("token-qr");
|
||||
const payload = JSON.stringify({
|
||||
url: location.origin,
|
||||
token: out.token,
|
||||
name: "Grotti",
|
||||
});
|
||||
try {
|
||||
QRCode.toCanvas(canvas, payload, { width: 180, margin: 1, errorCorrectionLevel: "M" });
|
||||
qrWrap.hidden = false;
|
||||
} catch (error) { /* QR optional */ }
|
||||
}
|
||||
} catch (error) {
|
||||
showNote("token-note", error);
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Kameras</h1>
|
||||
<p class="tiny">Livebilder aller Drucker.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<label class="field cam-mode-field">
|
||||
<span class="tiny">Bild</span>
|
||||
<select id="cam-mode" title="Kamera-Modus">
|
||||
<option value="snapshot">Snapshot</option>
|
||||
<option value="stream">Stream</option>
|
||||
</select>
|
||||
</label>
|
||||
<span id="when"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="camera-grid" id="grid"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const tiles = new Map();
|
||||
|
||||
(function initCamMode() {
|
||||
const sel = document.getElementById("cam-mode");
|
||||
if (!sel || typeof cameraMode !== "function") return;
|
||||
sel.value = cameraMode();
|
||||
sel.addEventListener("change", () => {
|
||||
setCameraMode(sel.value);
|
||||
for (const tile of tiles.values()) {
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
if (img) img.dataset.srcKey = "";
|
||||
}
|
||||
refresh().catch(() => {});
|
||||
});
|
||||
window.addEventListener("grotti-camera-mode", () => {
|
||||
sel.value = cameraMode();
|
||||
});
|
||||
})();
|
||||
|
||||
function metaLine(printer) {
|
||||
const state = shownState(printer);
|
||||
const busy = state === "printing" || state === "paused";
|
||||
const bits = [chipHtml(state, STATE_LABELS[state] || state)];
|
||||
if (busy) {
|
||||
const pct = Math.round((printer.progress || 0) * 100);
|
||||
bits.push(pct + " %");
|
||||
const eta = finishClock(printer.remain);
|
||||
if (eta) bits.push("fertig " + eta);
|
||||
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
|
||||
}
|
||||
const job = (printer.filename || "").replace(/\.gcode$/i, "").replace(/^(\d+-)+/, "");
|
||||
if (busy && job) bits.push(escapeHtml(job));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function ensureTile(printer) {
|
||||
let tile = tiles.get(printer.host);
|
||||
if (tile) return tile;
|
||||
tile = document.createElement("figure");
|
||||
tile.className = "camera-tile";
|
||||
tile.dataset.host = printer.host;
|
||||
tile.innerHTML = `
|
||||
<figcaption>
|
||||
<span class="cam-name"></span>
|
||||
<span class="host"></span>
|
||||
</figcaption>
|
||||
<p class="cam-meta tiny"></p>
|
||||
<div class="cam-stage">
|
||||
<img alt="" data-role="cam" hidden>
|
||||
<p class="cam-empty" data-role="cam-empty">${icon("camera")}<span>Keine Kamera</span></p>
|
||||
</div>
|
||||
<a class="ghost cam-open" href="/#${encodeURIComponent(printer.host)}">Cockpit</a>`;
|
||||
tiles.set(printer.host, tile);
|
||||
document.getElementById("grid").appendChild(tile);
|
||||
return tile;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const printers = await api("/drucker?live=1");
|
||||
document.getElementById("when").textContent = new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
const grid = document.getElementById("grid");
|
||||
if (!printers.length) {
|
||||
tiles.clear();
|
||||
grid.innerHTML = `<div class="empty-state">${icon("camera", "lg")}<p>Noch keine Drucker.</p><a href="/einstellungen">Zu den Einstellungen</a></div>`;
|
||||
return;
|
||||
}
|
||||
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
|
||||
const streaming = cameraMode() === "stream";
|
||||
const seen = new Set();
|
||||
for (const printer of printers) {
|
||||
seen.add(printer.host);
|
||||
const tile = ensureTile(printer);
|
||||
const state = shownState(printer);
|
||||
tile.className = "camera-tile " + state + (streaming ? " stream" : "");
|
||||
tile.querySelector(".cam-name").textContent = printer.name;
|
||||
tile.querySelector(".host").textContent = printer.host;
|
||||
tile.querySelector(".cam-meta").innerHTML = metaLine(printer);
|
||||
tile.querySelector(".cam-open").href = "/#" + encodeURIComponent(printer.host);
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
const empty = tile.querySelector('[data-role="cam-empty"]');
|
||||
if (!printer.camera || printer.has_camera === false) {
|
||||
img.removeAttribute("src");
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
img.dataset.srcKey = "";
|
||||
continue;
|
||||
}
|
||||
img.alt = "Kamera " + printer.name;
|
||||
const next = cameraUrl(printer, streaming ? 0 : 2000);
|
||||
if (img.dataset.srcKey === next) continue;
|
||||
img.dataset.srcKey = next;
|
||||
img.onerror = () => {
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
};
|
||||
img.onload = () => {
|
||||
img.hidden = false;
|
||||
empty.hidden = true;
|
||||
};
|
||||
img.src = next;
|
||||
}
|
||||
for (const [host, tile] of [...tiles.entries()]) {
|
||||
if (!seen.has(host)) {
|
||||
tile.remove();
|
||||
tiles.delete(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh().catch((error) => console.error(error));
|
||||
setInterval(() => refresh().catch(() => {}), 5000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,7 @@
|
||||
<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=70">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=73">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
@@ -82,7 +82,7 @@
|
||||
<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-file"/></svg><span>Dateien</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="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-clipboard"/></svg><span>Aufträge</span></a>
|
||||
<span class="nav-label nav-desk">Rückschau</span>
|
||||
<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-chart"/></svg><span>Statistik</span></a>
|
||||
@@ -138,11 +138,14 @@
|
||||
</nav>
|
||||
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
||||
<nav aria-label="Weitere Seiten">
|
||||
<p class="nav-label">Live</p>
|
||||
<a href="/auftraege">Aufträge</a>
|
||||
<a href="/projekte">Projekte</a>
|
||||
<p class="nav-label">Rückschau</p>
|
||||
<a href="/protokoll">Protokoll</a>
|
||||
<a href="/statistik">Statistik</a>
|
||||
<a href="/aktivitaet">Aktivität</a>
|
||||
<p class="nav-label">Verwalten</p>
|
||||
<a href="/projekte">Projekte</a>
|
||||
<a href="/material">Material</a>
|
||||
<a href="/wartung">Wartung</a>
|
||||
<a href="/einstellungen">Einstellungen</a>
|
||||
@@ -466,7 +469,12 @@ async function refreshJetzt() {
|
||||
}
|
||||
if (jetztBox) {
|
||||
jetztBox.classList.toggle("is-empty", !actionItems.length);
|
||||
setListHtml(document.getElementById("jetzt-list"), actionItems.map(actionItemHtml).join(""));
|
||||
const jetztList = document.getElementById("jetzt-list");
|
||||
if (!actionItems.length) {
|
||||
setListHtml(jetztList, '<li class="queue-empty">Alles erledigt — nichts zu tun.</li>');
|
||||
} else {
|
||||
setListHtml(jetztList, actionItems.map(actionItemHtml).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
const runBox = document.getElementById("run-box");
|
||||
@@ -607,13 +615,17 @@ refreshJetzt();
|
||||
(function liveUpdates() {
|
||||
let streamLive = false;
|
||||
const sseKinds = {
|
||||
queue: () => refreshJetzt(),
|
||||
queue: () => {
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
},
|
||||
senden: () => refreshJetzt(),
|
||||
ereignis: () => refreshJetzt(),
|
||||
aufgaben: () => refreshJetzt(),
|
||||
drucker: () => {
|
||||
refreshJetzt();
|
||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -524,8 +524,13 @@ async function loadAkte(spoolId) {
|
||||
const charge = run.chargen_nr
|
||||
? '<a class="tag akte-charge" href="' + href + '">' + escapeHtml(run.chargen_nr) + "</a>"
|
||||
: '<span class="akte-charge tiny">—</span>';
|
||||
const file = '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
|
||||
+ escapeHtml(akteFile(run.filename)) + "</a>";
|
||||
const fileName = akteFile(run.filename);
|
||||
const file = run.file_id
|
||||
? '<a class="akte-file" href="/dateien?file=' + encodeURIComponent(run.file_id) + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
|
||||
+ escapeHtml(fileName) + "</a>"
|
||||
+ ' <a class="tiny" href="' + href + '">Lauf</a>'
|
||||
: '<a class="akte-file" href="' + href + '" title="' + escapeHtml((run.filename || "").split("/").pop() || "") + '">'
|
||||
+ escapeHtml(fileName) + "</a>";
|
||||
const gramsTxt = grams(run.spool_weight_g) + (run.spool_is_estimate ? " ≈" : "");
|
||||
return '<div class="akte-run">'
|
||||
+ '<span class="akte-when tiny">' + escapeHtml(akteWhen(run.started_at)) + "</span>"
|
||||
@@ -1115,11 +1120,24 @@ load().then(() => {
|
||||
closeSpoolPanel();
|
||||
closeSortenPanel();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const spoolId = params.get("spool");
|
||||
const spoolId = params.get("spule") || params.get("spool");
|
||||
const hostHint = params.get("host") || "";
|
||||
const slotHint = params.get("slot") || "";
|
||||
if (spoolId) {
|
||||
const spool = spools.find((row) => String(row.id) === spoolId);
|
||||
if (spool) fill(spool);
|
||||
else openByCode(spoolId);
|
||||
if (spool) {
|
||||
fill(spool);
|
||||
focusById("spool-id") || document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
} else openByCode(spoolId);
|
||||
} else if (hostHint) {
|
||||
document.getElementById("spool-panel")?.removeAttribute("hidden");
|
||||
document.getElementById("spool-new")?.click?.();
|
||||
const hostField = document.getElementById("spool-printer");
|
||||
if (hostField) hostField.value = hostHint;
|
||||
const slotField = document.getElementById("spool-slot");
|
||||
if (slotField && slotHint) slotField.value = String(slotHint);
|
||||
document.getElementById("spool-name")?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
toast("Drucker und Platz vorausgefüllt — Rolle wählen und speichern.");
|
||||
}
|
||||
}).catch((error) => showNote("spool-note", error));
|
||||
</script>
|
||||
|
||||
+345
-56
@@ -6,11 +6,16 @@
|
||||
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="seg" id="proj-filter" role="group" aria-label="Filter">
|
||||
<button type="button" class="on" data-filter="aktiv">Aktiv</button>
|
||||
<button type="button" data-filter="fertig">Fertig</button>
|
||||
<button type="button" data-filter="">Alle</button>
|
||||
</div>
|
||||
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="split edit-split is-idle" id="proj-layout">
|
||||
<section class="card">
|
||||
<div class="card-head lib-head">
|
||||
<svg class="ico head-ico"><use href="#i-folder"/></svg>
|
||||
@@ -19,16 +24,21 @@
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th><th class="num">Kosten</th></tr></thead>
|
||||
<tbody id="proj-rows"></tbody>
|
||||
</table>
|
||||
<div class="pager" id="proj-pager" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="proj-panel">
|
||||
<h2 id="proj-title">Neues Projekt</h2>
|
||||
<form id="proj-form" novalidate>
|
||||
<section class="card edit-panel" id="proj-panel" hidden>
|
||||
<div class="mat-form-head">
|
||||
<h2 id="proj-title">Neues Projekt</h2>
|
||||
<button type="button" class="ghost tiny-btn" id="proj-edit-btn" hidden>Bearbeiten</button>
|
||||
<button type="button" class="icon-btn" id="proj-close" title="Schließen" aria-label="Schließen"></button>
|
||||
</div>
|
||||
|
||||
<form id="proj-form" novalidate hidden>
|
||||
<input id="proj-id" type="hidden">
|
||||
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
|
||||
<div class="grid-2">
|
||||
@@ -36,9 +46,9 @@
|
||||
<label for="proj-status">Status</label>
|
||||
<select id="proj-status">
|
||||
<option value="idee">Idee</option>
|
||||
<option value="bereit">bereit</option>
|
||||
<option value="druckt">druckt</option>
|
||||
<option value="fertig">fertig</option>
|
||||
<option value="bereit">Bereit</option>
|
||||
<option value="druckt">Druckt</option>
|
||||
<option value="fertig">Fertig</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -50,23 +60,46 @@
|
||||
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
|
||||
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
|
||||
<button type="button" class="ghost" id="proj-cancel-edit" hidden>Abbrechen</button>
|
||||
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="proj-note" class="tiny"></p>
|
||||
|
||||
<div id="proj-detail" hidden>
|
||||
<h3 class="section-gap">Druckdateien</h3>
|
||||
<div id="proj-gcodes" class="proj-file-list"></div>
|
||||
|
||||
<h3 class="section-gap">Weitere Dateien</h3>
|
||||
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
|
||||
<div class="actions" style="margin-bottom:0.5rem">
|
||||
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
|
||||
<input id="proj-asset-file" type="file" multiple hidden>
|
||||
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
|
||||
</div>
|
||||
<div id="proj-stats" class="proj-stats"></div>
|
||||
<p id="proj-suggest" class="tiny" hidden></p>
|
||||
<p id="proj-meta" class="tiny"></p>
|
||||
|
||||
<div class="seg" id="proj-tabs" role="tablist" style="margin:0.75rem 0">
|
||||
<button type="button" class="on" data-tab="files">Druckdateien</button>
|
||||
<button type="button" data-tab="runs">Läufe</button>
|
||||
<button type="button" data-tab="assets">Weitere Dateien</button>
|
||||
</div>
|
||||
|
||||
<div id="proj-tab-files">
|
||||
<div class="actions" style="margin-bottom:0.5rem">
|
||||
<button type="button" class="btn-primary" id="proj-enqueue-all" hidden>Alle einreihen</button>
|
||||
</div>
|
||||
<div id="proj-gcodes" class="proj-file-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="proj-tab-runs" hidden>
|
||||
<div id="proj-runs" class="proj-file-list"></div>
|
||||
<div class="pager" id="proj-runs-pager" hidden></div>
|
||||
</div>
|
||||
|
||||
<div id="proj-tab-assets" hidden>
|
||||
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
|
||||
<div class="actions" style="margin-bottom:0.5rem">
|
||||
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
|
||||
<input id="proj-asset-file" type="file" multiple hidden>
|
||||
</div>
|
||||
<div id="proj-assets" class="proj-file-list"></div>
|
||||
</div>
|
||||
<div id="proj-assets" class="proj-file-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -74,51 +107,117 @@
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
|
||||
const STATUS_LABEL = { idee: "Idee", bereit: "Bereit", druckt: "Druckt", fertig: "Fertig" };
|
||||
let projects = [];
|
||||
let selectedId = null;
|
||||
let listFilter = "aktiv";
|
||||
let detailTab = "files";
|
||||
let runsPage = 0;
|
||||
let runsTotal = 0;
|
||||
const RUNS_PAGE = 20;
|
||||
/** idle | create | view | edit */
|
||||
let panelMode = "idle";
|
||||
let folderTouched = false;
|
||||
let projPage = 1;
|
||||
|
||||
function fillBlank() {
|
||||
document.getElementById("proj-close").innerHTML = icon("x");
|
||||
|
||||
function openPanel() {
|
||||
document.getElementById("proj-panel").hidden = false;
|
||||
document.getElementById("proj-layout").classList.remove("is-idle");
|
||||
document.getElementById("proj-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
panelMode = "idle";
|
||||
selectedId = null;
|
||||
document.getElementById("proj-id").value = "";
|
||||
document.getElementById("proj-name").value = "";
|
||||
document.getElementById("proj-status").value = "idee";
|
||||
document.getElementById("proj-folder").value = "";
|
||||
document.getElementById("proj-notes").value = "";
|
||||
document.getElementById("proj-title").textContent = "Neues Projekt";
|
||||
document.getElementById("proj-panel").hidden = true;
|
||||
document.getElementById("proj-layout").classList.add("is-idle");
|
||||
document.getElementById("proj-form").hidden = true;
|
||||
document.getElementById("proj-detail").hidden = true;
|
||||
document.getElementById("proj-edit-btn").hidden = true;
|
||||
document.getElementById("proj-cancel-edit").hidden = true;
|
||||
document.getElementById("proj-delete").hidden = true;
|
||||
document.getElementById("proj-files-link").hidden = true;
|
||||
document.getElementById("proj-detail").hidden = true;
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
document.getElementById("proj-enqueue-all").hidden = true;
|
||||
history.replaceState(null, "", location.pathname);
|
||||
renderList();
|
||||
}
|
||||
|
||||
function fill(project) {
|
||||
function setFormValues(project) {
|
||||
document.getElementById("proj-id").value = project ? project.id : "";
|
||||
document.getElementById("proj-name").value = project ? (project.name || "") : "";
|
||||
document.getElementById("proj-status").value = project ? (project.status || "idee") : "idee";
|
||||
document.getElementById("proj-folder").value = project ? (project.folder || "") : "";
|
||||
document.getElementById("proj-notes").value = project ? (project.notes || "") : "";
|
||||
}
|
||||
|
||||
function showCreate() {
|
||||
panelMode = "create";
|
||||
selectedId = null;
|
||||
folderTouched = false;
|
||||
setFormValues(null);
|
||||
document.getElementById("proj-title").textContent = "Neues Projekt";
|
||||
document.getElementById("proj-form").hidden = false;
|
||||
document.getElementById("proj-detail").hidden = true;
|
||||
document.getElementById("proj-edit-btn").hidden = true;
|
||||
document.getElementById("proj-cancel-edit").hidden = true;
|
||||
document.getElementById("proj-delete").hidden = true;
|
||||
document.getElementById("proj-files-link").hidden = true;
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
document.getElementById("proj-enqueue-all").hidden = true;
|
||||
openPanel();
|
||||
renderList();
|
||||
document.getElementById("proj-name").focus();
|
||||
}
|
||||
|
||||
function showView(project) {
|
||||
panelMode = "view";
|
||||
selectedId = project.id;
|
||||
document.getElementById("proj-id").value = project.id;
|
||||
document.getElementById("proj-name").value = project.name || "";
|
||||
document.getElementById("proj-status").value = project.status || "idee";
|
||||
document.getElementById("proj-folder").value = project.folder || "";
|
||||
document.getElementById("proj-notes").value = project.notes || "";
|
||||
setFormValues(project);
|
||||
document.getElementById("proj-title").textContent = project.name || "Projekt";
|
||||
document.getElementById("proj-delete").hidden = false;
|
||||
document.getElementById("proj-form").hidden = true;
|
||||
document.getElementById("proj-detail").hidden = false;
|
||||
document.getElementById("proj-edit-btn").hidden = false;
|
||||
document.getElementById("proj-cancel-edit").hidden = true;
|
||||
document.getElementById("proj-delete").hidden = true;
|
||||
const link = document.getElementById("proj-files-link");
|
||||
link.hidden = false;
|
||||
link.href = "/dateien";
|
||||
document.getElementById("proj-detail").hidden = false;
|
||||
link.href = "/dateien?project=" + encodeURIComponent(project.id);
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
openPanel();
|
||||
renderList();
|
||||
renderDetail(project);
|
||||
}
|
||||
|
||||
let projPage = 1;
|
||||
function showEdit(project) {
|
||||
panelMode = "edit";
|
||||
selectedId = project.id;
|
||||
folderTouched = true;
|
||||
setFormValues(project);
|
||||
document.getElementById("proj-title").textContent = "Bearbeiten";
|
||||
document.getElementById("proj-form").hidden = false;
|
||||
document.getElementById("proj-detail").hidden = true;
|
||||
document.getElementById("proj-edit-btn").hidden = true;
|
||||
document.getElementById("proj-cancel-edit").hidden = false;
|
||||
document.getElementById("proj-delete").hidden = false;
|
||||
document.getElementById("proj-files-link").hidden = true;
|
||||
openPanel();
|
||||
renderList();
|
||||
document.getElementById("proj-name").focus();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const body = document.getElementById("proj-rows");
|
||||
const pager = document.getElementById("proj-pager");
|
||||
document.getElementById("proj-count").textContent = String(projects.length);
|
||||
if (!projects.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
||||
body.innerHTML = '<tr><td class="empty" colspan="5"><div class="empty-state">' + icon("folder")
|
||||
+ "<p>Noch kein Projekt.</p>"
|
||||
+ '<p><button type="button" class="btn-primary" id="proj-empty-new">Erstes Projekt anlegen</button></p>'
|
||||
+ "</div></td></tr>";
|
||||
document.getElementById("proj-empty-new")?.addEventListener("click", showCreate);
|
||||
renderPager(pager, null, () => {});
|
||||
return;
|
||||
}
|
||||
@@ -128,12 +227,16 @@ function renderList() {
|
||||
paged.items.forEach((project) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></td>";
|
||||
tr.innerHTML = "<td></td><td></td><td class=\"cell-path\"></td><td class=\"num\"></td><td class=\"num\"></td>";
|
||||
tr.children[0].textContent = project.name;
|
||||
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
|
||||
let status = STATUS_LABEL[project.status] || project.status || "";
|
||||
if ((project.stats || {}).active_count > 0) status = STATUS_LABEL.druckt;
|
||||
tr.children[1].textContent = status;
|
||||
tr.children[2].textContent = project.folder || "—";
|
||||
tr.children[2].title = project.folder || "";
|
||||
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
|
||||
const cost = (project.stats || {}).cost;
|
||||
tr.children[4].textContent = cost ? euro(cost) : "—";
|
||||
tr.addEventListener("click", () => openProject(project.id));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
@@ -143,20 +246,122 @@ function renderList() {
|
||||
});
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
detailTab = tab;
|
||||
document.querySelectorAll("#proj-tabs [data-tab]").forEach((btn) => {
|
||||
btn.classList.toggle("on", btn.dataset.tab === tab);
|
||||
});
|
||||
document.getElementById("proj-tab-files").hidden = tab !== "files";
|
||||
document.getElementById("proj-tab-runs").hidden = tab !== "runs";
|
||||
document.getElementById("proj-tab-assets").hidden = tab !== "assets";
|
||||
if (tab === "runs" && selectedId) loadRuns(selectedId, 0);
|
||||
if (tab === "assets" && selectedId) loadAssets(selectedId);
|
||||
}
|
||||
|
||||
function renderDetail(project) {
|
||||
const gcodes = document.getElementById("proj-gcodes");
|
||||
const stats = project.stats || {};
|
||||
const statsEl = document.getElementById("proj-stats");
|
||||
const chips = [
|
||||
[stats.run_count || 0, "Läufe"],
|
||||
[hours(stats.duration_s) || "0 min", "Zeit"],
|
||||
[grams(stats.weight_g || 0), "Filament"],
|
||||
[euro(stats.cost || 0), "Kosten"],
|
||||
];
|
||||
if (stats.files_total) {
|
||||
chips.push([stats.files_ok + "/" + stats.files_total, "Dateien ok"]);
|
||||
}
|
||||
statsEl.innerHTML = chips.map(([v, l]) =>
|
||||
`<div class="proj-stat"><strong>${escapeHtml(String(v))}</strong><span class="tiny">${escapeHtml(l)}</span></div>`
|
||||
).join("");
|
||||
|
||||
const meta = [];
|
||||
if (project.folder) meta.push(project.folder);
|
||||
if (project.created_at) meta.push("Angelegt " + formatDateTime(project.created_at));
|
||||
if (stats.last_run) {
|
||||
meta.push("Zuletzt " + formatDateTime(stats.last_run.started_at) + " · " + (stats.last_run.filename || ""));
|
||||
}
|
||||
document.getElementById("proj-meta").textContent = meta.join(" · ");
|
||||
|
||||
const suggest = document.getElementById("proj-suggest");
|
||||
if (stats.suggest_fertig) {
|
||||
suggest.hidden = false;
|
||||
suggest.innerHTML = 'Alle Dateien mindestens einmal ok gedruckt. <button type="button" class="ghost" id="proj-mark-fertig">Als fertig markieren?</button>';
|
||||
document.getElementById("proj-mark-fertig")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/projekte/" + project.id, { method: "PATCH", json: { status: "fertig" } });
|
||||
toast("Projekt als fertig markiert.", "good");
|
||||
await load();
|
||||
await openProject(project.id);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
suggest.hidden = true;
|
||||
suggest.textContent = "";
|
||||
}
|
||||
|
||||
const files = project.files || [];
|
||||
document.getElementById("proj-enqueue-all").hidden = !files.length;
|
||||
const gcodes = document.getElementById("proj-gcodes");
|
||||
if (!files.length) {
|
||||
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
|
||||
} else {
|
||||
gcodes.innerHTML = files.map((file) => {
|
||||
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
|
||||
const mat = [...new Set(mats)].join(" / ");
|
||||
const meta = [mat, file.weight_g ? Math.round(file.weight_g) + " g" : ""].filter(Boolean).join(" · ");
|
||||
return `<a class="proj-file" href="/dateien">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
|
||||
const metaBits = [
|
||||
mat,
|
||||
file.weight_g ? Math.round(file.weight_g) + " g" : "",
|
||||
file.printed ? file.printed + "× gedruckt" : "noch nie",
|
||||
file.queued ? file.queued + " in Queue" : "",
|
||||
file.ok_once ? "ok" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
const href = "/dateien?file=" + encodeURIComponent(file.id) + "&project=" + encodeURIComponent(project.id);
|
||||
return `<div class="proj-file-row">
|
||||
<a class="proj-file" href="${escapeHtml(href)}">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(metaBits)}</span></a>
|
||||
<button type="button" class="ghost" data-enqueue="${file.id}">Einreihen</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
loadAssets(project.id);
|
||||
setTab(detailTab);
|
||||
if (detailTab === "assets") loadAssets(project.id);
|
||||
if (detailTab === "runs") loadRuns(project.id, runsPage);
|
||||
}
|
||||
|
||||
async function loadRuns(projectId, offset) {
|
||||
const box = document.getElementById("proj-runs");
|
||||
const pager = document.getElementById("proj-runs-pager");
|
||||
runsPage = Math.max(0, offset || 0);
|
||||
try {
|
||||
const resp = await fetch(withToken("/api/v1/projekte/" + projectId + "/laeufe?limit=" + RUNS_PAGE + "&offset=" + runsPage), {
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) throw new Error("Läufe laden fehlgeschlagen.");
|
||||
const rows = await resp.json();
|
||||
runsTotal = Number(resp.headers.get("X-Total-Count") || rows.length);
|
||||
if (!rows.length) {
|
||||
box.innerHTML = '<p class="tiny">Noch keine Läufe.</p>';
|
||||
pager.hidden = true;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = rows.map((run) => {
|
||||
const state = (window.RUN_STATES && RUN_STATES[run.state]) || run.state || "";
|
||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||
const meta = [state, hours(run.duration_s), cost ? euro(cost) : "", formatDateTime(run.started_at)].filter(Boolean).join(" · ");
|
||||
return `<a class="proj-file" href="/protokoll/${run.id}">${escapeHtml(run.filename || "Lauf #" + run.id)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
|
||||
}).join("");
|
||||
const pages = Math.max(1, Math.ceil(runsTotal / RUNS_PAGE));
|
||||
const page = Math.floor(runsPage / RUNS_PAGE) + 1;
|
||||
renderPager(pager, {
|
||||
items: rows, page, pages, total: runsTotal, pageSize: RUNS_PAGE,
|
||||
from: runsTotal ? runsPage + 1 : 0,
|
||||
to: Math.min(runsPage + rows.length, runsTotal),
|
||||
}, (p) => loadRuns(projectId, (p - 1) * RUNS_PAGE));
|
||||
} catch (error) {
|
||||
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssets(projectId) {
|
||||
@@ -187,32 +392,80 @@ function sizeText(bytes) {
|
||||
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
|
||||
}
|
||||
|
||||
async function enqueueFiles(projectId, fileIds) {
|
||||
const payload = { confirm: true };
|
||||
if (fileIds && fileIds.length) payload.file_ids = fileIds;
|
||||
const out = await api("/projekte/" + projectId + "/einreihen", { method: "POST", json: payload });
|
||||
toast((out.count || 0) + " in die Warteschlange.", "good");
|
||||
await openProject(projectId);
|
||||
}
|
||||
|
||||
async function openProject(id) {
|
||||
try {
|
||||
const row = await api("/projekte/" + id);
|
||||
fill(row);
|
||||
showView(row);
|
||||
history.replaceState(null, "", "#" + id);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
projects = await api("/projekte");
|
||||
const q = listFilter ? ("?status=" + encodeURIComponent(listFilter)) : "";
|
||||
projects = await api("/projekte" + q);
|
||||
renderList();
|
||||
if (selectedId) {
|
||||
if (selectedId && (panelMode === "view" || panelMode === "edit")) {
|
||||
const still = projects.find((p) => String(p.id) === String(selectedId));
|
||||
if (still) await openProject(selectedId);
|
||||
else fillBlank();
|
||||
if (still) {
|
||||
if (panelMode === "edit") {
|
||||
const row = await api("/projekte/" + selectedId);
|
||||
showEdit(row);
|
||||
} else {
|
||||
await openProject(selectedId);
|
||||
}
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let folderTouched = false;
|
||||
document.getElementById("proj-filter").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-filter]");
|
||||
if (!btn) return;
|
||||
listFilter = btn.dataset.filter || "";
|
||||
document.querySelectorAll("#proj-filter [data-filter]").forEach((b) => b.classList.toggle("on", b === btn));
|
||||
projPage = 1;
|
||||
load().catch((error) => showNote("proj-note", error));
|
||||
});
|
||||
|
||||
document.getElementById("proj-tabs").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-tab]");
|
||||
if (!btn) return;
|
||||
setTab(btn.dataset.tab);
|
||||
});
|
||||
|
||||
document.getElementById("proj-new").addEventListener("click", () => {
|
||||
folderTouched = false;
|
||||
fillBlank();
|
||||
document.getElementById("proj-name").focus();
|
||||
history.replaceState(null, "", location.pathname);
|
||||
showCreate();
|
||||
});
|
||||
document.getElementById("proj-close").addEventListener("click", closePanel);
|
||||
document.getElementById("proj-edit-btn").addEventListener("click", async () => {
|
||||
if (!selectedId) return;
|
||||
try {
|
||||
const row = await api("/projekte/" + selectedId);
|
||||
showEdit(row);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("proj-cancel-edit").addEventListener("click", async () => {
|
||||
if (!selectedId) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
await openProject(selectedId);
|
||||
});
|
||||
|
||||
document.getElementById("proj-folder").addEventListener("input", () => {
|
||||
folderTouched = true;
|
||||
});
|
||||
@@ -278,7 +531,7 @@ document.getElementById("proj-delete").addEventListener("click", () => {
|
||||
try {
|
||||
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
|
||||
toast("Projekt gelöscht.");
|
||||
fillBlank();
|
||||
closePanel();
|
||||
await load();
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
@@ -286,8 +539,36 @@ document.getElementById("proj-delete").addEventListener("click", () => {
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("proj-enqueue-all").addEventListener("click", () => {
|
||||
const id = selectedId;
|
||||
if (!id) return;
|
||||
armButton(document.getElementById("proj-enqueue-all"), "Alle einreihen?", async () => {
|
||||
try {
|
||||
await enqueueFiles(id, null);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("proj-gcodes").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-enqueue]");
|
||||
if (!btn) return;
|
||||
event.preventDefault();
|
||||
const id = selectedId;
|
||||
const fileId = Number(btn.dataset.enqueue);
|
||||
if (!id || !fileId) return;
|
||||
armButton(btn, "einreihen?", async () => {
|
||||
try {
|
||||
await enqueueFiles(id, [fileId]);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const id = selectedId;
|
||||
const files = [...(event.target.files || [])];
|
||||
event.target.value = "";
|
||||
if (!id || !files.length) return;
|
||||
@@ -307,7 +588,7 @@ document.getElementById("proj-asset-file").addEventListener("change", async (eve
|
||||
document.getElementById("proj-assets").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-asset-del]");
|
||||
if (!btn) return;
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const id = selectedId;
|
||||
const rel = btn.dataset.assetDel;
|
||||
if (!id || !rel) return;
|
||||
armButton(btn, "löschen?", async () => {
|
||||
@@ -317,6 +598,14 @@ document.getElementById("proj-assets").addEventListener("click", (event) => {
|
||||
});
|
||||
});
|
||||
|
||||
load().catch((error) => showNote("proj-note", error));
|
||||
(async () => {
|
||||
try {
|
||||
await load();
|
||||
const hash = (location.hash || "").replace(/^#/, "");
|
||||
if (hash && /^\d+$/.test(hash)) await openProject(Number(hash));
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,10 @@
|
||||
<div>
|
||||
<h1>Protokoll</h1>
|
||||
<p class="tiny">Abgeschlossene und laufende Drucke mit Dauer, Material und Kosten.</p>
|
||||
<details class="tiny proto-help">
|
||||
<summary>Filament-Pause / doppelte Zeilen</summary>
|
||||
<p>Nach Runout kann der Drucker kurz zwei History-Zeilen erzeugen. „Läufe holen“ legt sie zusammen; sonst „Verbinden“.</p>
|
||||
</details>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
|
||||
@@ -96,7 +100,6 @@ function render() {
|
||||
runPage = paged.page;
|
||||
paged.items.forEach((run) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = run.printer_name;
|
||||
tr.children[0].className = "proto-printer";
|
||||
@@ -109,19 +112,54 @@ function render() {
|
||||
link.title = fullName;
|
||||
link.textContent = shortRunName(run.filename) || fullName;
|
||||
fileCell.appendChild(link);
|
||||
if (run.file_id) {
|
||||
const fileLink = document.createElement("a");
|
||||
fileLink.href = "/dateien?file=" + encodeURIComponent(run.file_id);
|
||||
fileLink.className = "tiny";
|
||||
fileLink.textContent = "Datei";
|
||||
fileLink.style.marginLeft = "0.35rem";
|
||||
fileCell.appendChild(fileLink);
|
||||
}
|
||||
if (run.project_id) {
|
||||
const projLink = document.createElement("a");
|
||||
projLink.href = "/projekte#" + encodeURIComponent(run.project_id);
|
||||
projLink.className = "tiny";
|
||||
projLink.textContent = "Projekt";
|
||||
projLink.style.marginLeft = "0.35rem";
|
||||
fileCell.appendChild(projLink);
|
||||
}
|
||||
if (run.chargen_nr) {
|
||||
const code = document.createElement("div");
|
||||
code.className = "mono-badge";
|
||||
code.textContent = run.chargen_nr;
|
||||
fileCell.appendChild(code);
|
||||
}
|
||||
if ((run.spool_names || []).length > 1 || (run.segment_count || 0) > 1) {
|
||||
const swap = document.createElement("div");
|
||||
swap.className = "tiny warn-text";
|
||||
swap.textContent = "Rollenwechsel: " + (run.spool_names || []).join(" → ");
|
||||
fileCell.appendChild(swap);
|
||||
}
|
||||
if (run.merge_with_id) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "tiny warn-text";
|
||||
hint.textContent = "Vermutlich Fortsetzung von/zu #" + run.merge_with_id;
|
||||
fileCell.appendChild(hint);
|
||||
}
|
||||
const stateKey = run.state === "in_progress" || run.state === "printing" ? "printing"
|
||||
: run.state === "paused" ? "paused"
|
||||
: run.state === "completed" ? "complete"
|
||||
: run.state === "error" ? "error"
|
||||
: run.state === "cancelled" || run.state === "interrupted" ? "cancelled"
|
||||
: "idle";
|
||||
tr.children[2].innerHTML = chipHtml(stateKey, states[run.state] || run.state);
|
||||
tr.children[3].textContent = hours(run.duration_s);
|
||||
let stateLabel = states[run.state] || run.state;
|
||||
if (run.state === "cancelled" && run.merge_with_id) stateLabel = "Teilabbruch (Runout?)";
|
||||
tr.children[2].innerHTML = chipHtml(stateKey, stateLabel);
|
||||
let durationText = hours(run.duration_s);
|
||||
if (run.pause_s && run.pause_s > 60) {
|
||||
durationText += " (Pause " + hours(run.pause_s) + ")";
|
||||
}
|
||||
tr.children[3].textContent = durationText;
|
||||
tr.children[3].className = "num";
|
||||
const used = Number(run.weight_used_g) || Number(run.weight_g) || 0;
|
||||
const planned = Number(run.weight_planned_g) || Number(run.weight_g) || 0;
|
||||
@@ -134,7 +172,11 @@ function render() {
|
||||
}
|
||||
const cost = Number(run.filament_cost || 0) + Number(run.energy_cost || 0);
|
||||
tr.children[5].textContent = cost ? euro(cost) : "";
|
||||
const verdictLabel = document.createElement("label");
|
||||
verdictLabel.className = "sr-only";
|
||||
verdictLabel.textContent = "Urteil";
|
||||
const verdict = document.createElement("select");
|
||||
verdict.setAttribute("aria-label", "Urteil");
|
||||
[["", "offen"], ["gut", "gut"], ["ausschuss", "Ausschuss"]].forEach(([value, label]) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
@@ -145,10 +187,12 @@ function render() {
|
||||
verdict.addEventListener("change", async () => {
|
||||
try {
|
||||
await api("/laeufe/" + run.id, { method: "PATCH", json: { verdict: verdict.value } });
|
||||
toast("Urteil gespeichert.");
|
||||
} catch (error) {
|
||||
showNote("run-note", error);
|
||||
}
|
||||
});
|
||||
tr.children[6].appendChild(verdictLabel);
|
||||
tr.children[6].appendChild(verdict);
|
||||
if (run.needs_spool || !run.deducted) {
|
||||
const select = document.createElement("select");
|
||||
@@ -183,14 +227,49 @@ function render() {
|
||||
}
|
||||
});
|
||||
tr.children[7].appendChild(select);
|
||||
} else if ((run.spool_names || []).length > 1) {
|
||||
tr.children[7].className = "proto-spool";
|
||||
tr.children[7].textContent = run.spool_names.join(" → ");
|
||||
tr.children[7].title = run.spool_names.join(" → ");
|
||||
} else {
|
||||
const spool = spools.find((item) => item.id === run.spool_id);
|
||||
tr.children[7].textContent = spool ? spool.name : "abgezogen";
|
||||
tr.children[7].title = spool ? spool.name : "";
|
||||
tr.children[7].className = "proto-spool";
|
||||
if (spool) {
|
||||
tr.children[7].innerHTML = linkTo("spule", spool.id, spool.name);
|
||||
tr.children[7].title = spool.name;
|
||||
} else if ((run.spool_names || []).length) {
|
||||
tr.children[7].textContent = run.spool_names[0];
|
||||
} else {
|
||||
tr.children[7].textContent = "abgezogen";
|
||||
}
|
||||
}
|
||||
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
|
||||
if (run.file_id) {
|
||||
const actions = tr.children[8];
|
||||
actions.className = "proto-actions";
|
||||
if (run.merge_with_id) {
|
||||
const mergeBtn = document.createElement("button");
|
||||
mergeBtn.type = "button";
|
||||
mergeBtn.className = "ghost tiny-btn";
|
||||
mergeBtn.textContent = "Verbinden";
|
||||
mergeBtn.title = "Mit Lauf #" + run.merge_with_id + " verbinden";
|
||||
mergeBtn.addEventListener("click", () => {
|
||||
armButton(mergeBtn, "Mit #" + run.merge_with_id + " verbinden?", async () => {
|
||||
try {
|
||||
await api("/laeufe/" + run.id + "/verbinden", {
|
||||
method: "POST",
|
||||
json: { other_id: run.merge_with_id, confirm: true },
|
||||
});
|
||||
toast("Läufe verbunden.");
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast(error.message, "bad");
|
||||
}
|
||||
});
|
||||
});
|
||||
actions.appendChild(mergeBtn);
|
||||
}
|
||||
const running = ["in_progress", "printing", "paused"].includes(run.state);
|
||||
if (run.file_id && !running) {
|
||||
const again = document.createElement("button");
|
||||
again.type = "button";
|
||||
again.className = "ghost tiny-btn";
|
||||
@@ -235,7 +314,7 @@ function render() {
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[8].appendChild(again);
|
||||
actions.appendChild(again);
|
||||
}
|
||||
body.appendChild(tr);
|
||||
});
|
||||
@@ -250,6 +329,8 @@ async function load() {
|
||||
runPage = 1;
|
||||
render();
|
||||
}
|
||||
window.onDruckerLive = () => { load().catch(() => {}); };
|
||||
window.onQueueChanged = () => { load().catch(() => {}); };
|
||||
|
||||
["run-filter-printer", "run-filter-verdict", "run-filter-spool"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => { runPage = 1; render(); });
|
||||
|
||||
@@ -19,14 +19,18 @@
|
||||
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
|
||||
</div>
|
||||
<div class="stat-filters">
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
|
||||
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
|
||||
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
|
||||
</div>
|
||||
<details class="stat-more">
|
||||
<summary>Weitere Filter</summary>
|
||||
<div class="stat-filters">
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<select id="project"><option value="">Alle Projekte</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
|
||||
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
|
||||
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<p id="stat-note"></p>
|
||||
<div id="stat" class="statistik"></div>
|
||||
@@ -231,17 +235,37 @@ function render(data) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function fillProjects() {
|
||||
const select = document.getElementById("project");
|
||||
if (!select || select.dataset.filled) return;
|
||||
try {
|
||||
const rows = await api("/projekte?status=");
|
||||
const current = select.value;
|
||||
rows.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
select.dataset.filled = "1";
|
||||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||
} catch (error) { /* optional */ }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await fillProjects();
|
||||
const params = new URLSearchParams();
|
||||
const since = document.getElementById("since").value;
|
||||
const until = document.getElementById("until").value;
|
||||
const host = document.getElementById("host").value;
|
||||
const project = document.getElementById("project").value;
|
||||
const material = document.getElementById("material").value.trim();
|
||||
const brand = document.getElementById("brand").value.trim();
|
||||
const color = document.getElementById("color").value.trim();
|
||||
if (since) params.set("since", since);
|
||||
if (until) params.set("until", until);
|
||||
if (host) params.set("host", host);
|
||||
if (project) params.set("project_id", project);
|
||||
if (material) params.set("material", material);
|
||||
if (brand) params.set("brand", brand);
|
||||
if (color) params.set("color", color);
|
||||
@@ -278,6 +302,7 @@ document.getElementById("period").addEventListener("click", (event) => {
|
||||
});
|
||||
});
|
||||
document.getElementById("host").addEventListener("change", scheduleLoad);
|
||||
document.getElementById("project").addEventListener("change", scheduleLoad);
|
||||
["material", "brand", "color"].forEach((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
|
||||
+194
-3
@@ -49,6 +49,10 @@
|
||||
<div class="desk-facts" data-role="facts"></div>
|
||||
<div data-role="flow"></div>
|
||||
<div data-role="strips"></div>
|
||||
<div class="desk-next" data-role="next" hidden>
|
||||
<div class="desk-next-head"><svg class="ico"><use href="#i-play"/></svg><span class="grow">Als Nächstes</span></div>
|
||||
<div data-role="next-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desk-foot">
|
||||
<div class="card-actions" data-role="controls"></div>
|
||||
@@ -367,10 +371,15 @@ function stripsHtml(printer, state) {
|
||||
}
|
||||
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
|
||||
if (filamentHint) {
|
||||
const slot = (printer.slots || []).find((s) => s && (s.empty || s.runout)) || (printer.slots || [])[0];
|
||||
const slotN = slot && (slot.index != null ? Number(slot.index) + 1 : slot.slot);
|
||||
const matHref = "/material?host=" + encodeURIComponent(printer.host || "")
|
||||
+ (slotN ? "&slot=" + encodeURIComponent(slotN) : "");
|
||||
const slotTxt = slotN ? "ACE Platz " + slotN + ": " : "";
|
||||
bits.push(`<div class="strip warn filament-swap">
|
||||
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
|
||||
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
|
||||
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
|
||||
<div class="strip-head">${icon("alert")}<span class="grow"><b>${slotTxt}Neue Rolle in Material zuweisen</b></span></div>
|
||||
<span class="strip-msg">${escapeHtml(filamentHint)} — 1) Rolle dem Drucker zuweisen, 2) speichern, 3) Weiter. Verbrauch bleibt dieselbe Charge.</span>
|
||||
<div class="strip-actions"><a class="ghost" href="${matHref}">Zu Material</a></div>
|
||||
</div>`);
|
||||
}
|
||||
const failed = !queueBusy && printer.queue_failed;
|
||||
@@ -1065,6 +1074,156 @@ function patchDesk(printer) {
|
||||
// Kamera nur über Timer aktualisieren — sonst doppelter Reload + Layout-Ruckler
|
||||
syncDeskViewer(printer).catch(() => {});
|
||||
refreshDeskVerlauf(false);
|
||||
refreshDeskNext(printer, state);
|
||||
}
|
||||
|
||||
let deskNextAt = 0;
|
||||
let deskNextHost = "";
|
||||
let deskNextKey = "";
|
||||
|
||||
function deskCanOfferStart(printer, state) {
|
||||
if (!printer) return false;
|
||||
if (["printing", "paused", "heating", "offline", "error"].includes(state)) return false;
|
||||
if (printer.queue_running || startingJob(printer)) return false;
|
||||
if (needsClear(printer)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function deskMaterialTypes(printer) {
|
||||
const types = new Set();
|
||||
(printer.spools || []).forEach((s) => {
|
||||
if (s && s.material_type) types.add(String(s.material_type).toLowerCase());
|
||||
});
|
||||
(printer.ace_slots || printer.slots || []).forEach((s) => {
|
||||
const t = (s && (s.type || s.material_type)) || "";
|
||||
if (t) types.add(String(t).toLowerCase());
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
function fileMatchesPrinter(file, printer) {
|
||||
if (!file) return false;
|
||||
const family = printer.family || "";
|
||||
if (file.printer_family && family && file.printer_family !== family) return false;
|
||||
const types = deskMaterialTypes(printer);
|
||||
if (!types.size) return true;
|
||||
const mats = (file.filaments || []).map((f) => String(f.type || f.material_type || "").toLowerCase()).filter(Boolean);
|
||||
if (!mats.length) return true;
|
||||
return mats.some((m) => [...types].some((t) => t.includes(m) || m.includes(t)));
|
||||
}
|
||||
|
||||
async function refreshDeskNext(printer, state, force) {
|
||||
const box = deskEl.querySelector('[data-role="next"]');
|
||||
const body = deskEl.querySelector('[data-role="next-body"]');
|
||||
if (!box || !body) return;
|
||||
if (!deskCanOfferStart(printer, state)) {
|
||||
box.hidden = true;
|
||||
body.innerHTML = "";
|
||||
deskNextKey = "";
|
||||
return;
|
||||
}
|
||||
const host = printer.host;
|
||||
const key = host + "|" + (printer.queue_waiting || 0) + "|" + (printer.filename || "");
|
||||
const now = Date.now();
|
||||
if (!force && deskNextHost === host && deskNextKey === key && now - deskNextAt < 25000) {
|
||||
box.hidden = false;
|
||||
return;
|
||||
}
|
||||
deskNextAt = now;
|
||||
deskNextHost = host;
|
||||
deskNextKey = key;
|
||||
box.hidden = false;
|
||||
body.innerHTML = '<p class="tiny">Lade Vorschläge …</p>';
|
||||
try {
|
||||
const [last, queue, files] = await Promise.all([
|
||||
api("/senden-zuletzt?host=" + encodeURIComponent(host)).catch(() => null),
|
||||
api("/druck-queue?host=" + encodeURIComponent(host) + "&status=waiting&limit=5").catch(() => []),
|
||||
api("/dateien?family=" + encodeURIComponent(printer.family || "") + "&limit=40").catch(() => []),
|
||||
]);
|
||||
if (deskHost !== host) return;
|
||||
const bits = [];
|
||||
if (last && last.file_id) {
|
||||
const name = jobLabel(last.filename) || ("#" + last.file_id);
|
||||
bits.push(`<div class="desk-next-row">
|
||||
<div class="grow"><b>Nochmal</b><span class="tiny">${escapeHtml(name)}</span></div>
|
||||
<button type="button" class="btn-primary tiny-btn" data-next-again="${last.file_id}">Start</button>
|
||||
<a class="ghost tiny-btn" href="/dateien?host=${encodeURIComponent(host)}&file=${encodeURIComponent(last.file_id)}">Anpassen</a>
|
||||
</div>`);
|
||||
}
|
||||
const waiting = (Array.isArray(queue) ? queue : []).filter((j) => j.status === "waiting");
|
||||
if (waiting.length) {
|
||||
const job = waiting[0];
|
||||
bits.push(`<div class="desk-next-row">
|
||||
<div class="grow"><b>Warteschlange</b><span class="tiny">${escapeHtml(jobLabel(job.filename) || "Job")}</span></div>
|
||||
<a class="ghost tiny-btn" href="/warteschlange">Zur Queue</a>
|
||||
</div>`);
|
||||
}
|
||||
const matched = (Array.isArray(files) ? files : [])
|
||||
.filter((f) => fileMatchesPrinter(f, printer))
|
||||
.slice(0, 5);
|
||||
if (matched.length) {
|
||||
bits.push('<div class="desk-next-files"><span class="tiny">Passende Dateien</span>' + matched.map((f) => {
|
||||
const mats = [...new Set((f.filaments || []).map((x) => x.type || x.material_type).filter(Boolean))].join(" / ");
|
||||
return `<div class="desk-next-row">
|
||||
<div class="grow"><span>${escapeHtml(jobLabel(f.name) || f.name)}</span><span class="tiny">${escapeHtml(mats || "")}</span></div>
|
||||
<button type="button" class="ghost tiny-btn" data-next-start="${f.id}">Start</button>
|
||||
<a class="ghost tiny-btn" href="/dateien?host=${encodeURIComponent(host)}&file=${encodeURIComponent(f.id)}">Anpassen</a>
|
||||
</div>`;
|
||||
}).join("") + "</div>");
|
||||
}
|
||||
if (!bits.length) {
|
||||
body.innerHTML = `<p class="tiny">Keine Vorschläge. <a href="/dateien?host=${encodeURIComponent(host)}">Datei wählen →</a></p>`;
|
||||
} else {
|
||||
body.innerHTML = bits.join("");
|
||||
}
|
||||
} catch (error) {
|
||||
if (deskHost !== host) return;
|
||||
body.innerHTML = '<p class="tiny msg">' + escapeHtml(error.message || "Vorschläge fehlen") + "</p>";
|
||||
}
|
||||
}
|
||||
|
||||
async function deskStartFile(fileId, fromLast) {
|
||||
if (!deskHost || !fileId) return;
|
||||
const host = deskHost;
|
||||
let aceMap = [];
|
||||
let bedLevel = false;
|
||||
let spoolId = null;
|
||||
try {
|
||||
const last = await api(
|
||||
"/senden-zuletzt?host=" + encodeURIComponent(host)
|
||||
+ "&file_id=" + encodeURIComponent(fileId)
|
||||
);
|
||||
if (last) {
|
||||
aceMap = last.ace_map || [];
|
||||
bedLevel = !!last.bed_leveling;
|
||||
spoolId = last.spool_id || null;
|
||||
}
|
||||
} catch (error) { /* Defaults */ }
|
||||
if (fromLast && !aceMap.length) {
|
||||
try {
|
||||
const lastHost = await api("/senden-zuletzt?host=" + encodeURIComponent(host));
|
||||
if (lastHost && String(lastHost.file_id) === String(fileId)) {
|
||||
aceMap = lastHost.ace_map || [];
|
||||
bedLevel = !!lastHost.bed_leveling;
|
||||
spoolId = lastHost.spool_id || null;
|
||||
}
|
||||
} catch (error) { /* ignore */ }
|
||||
}
|
||||
const printer = printerCache.get(host);
|
||||
const canNow = printer && deskCanOfferStart(printer, shownState(printer));
|
||||
const payload = {
|
||||
file_id: Number(fileId),
|
||||
hosts: [host],
|
||||
confirm: true,
|
||||
when_free: !canNow,
|
||||
bed_leveling: bedLevel,
|
||||
};
|
||||
if (spoolId) payload.spool_id = spoolId;
|
||||
if (aceMap.length) payload.ace_maps = { [host]: aceMap };
|
||||
await api("/senden", { method: "POST", json: payload });
|
||||
toast(canNow ? "Druck startet…" : "In die Warteschlange.", "good");
|
||||
deskNextAt = 0;
|
||||
if (printer) refreshDeskNext(printer, shownState(printer), true);
|
||||
}
|
||||
|
||||
async function refreshDeskVerlauf(force) {
|
||||
@@ -1102,8 +1261,12 @@ async function refreshDeskVerlauf(force) {
|
||||
kwh,
|
||||
].filter(Boolean);
|
||||
if (caption) caption.textContent = bits.join(" · ");
|
||||
const fileBit = run.file_id
|
||||
? `<a class="ghost tiny-btn" href="/dateien?file=${encodeURIComponent(run.file_id)}">Datei</a>`
|
||||
: "";
|
||||
const head = `<div class="desk-verlauf-head">
|
||||
<span class="tiny grow">${escapeHtml(jobLabel(run.filename) || "Druck")}${run.chargen_nr ? " · " + escapeHtml(run.chargen_nr) : ""}</span>
|
||||
${fileBit}
|
||||
<a class="ghost tiny-btn" href="/protokoll/${run.id}">Bericht →</a>
|
||||
</div>`;
|
||||
const html = head + tempChartHtml(data.samples || []);
|
||||
@@ -1515,6 +1678,34 @@ window.addEventListener("keydown", (event) => {
|
||||
});
|
||||
|
||||
async function handleWallClick(event) {
|
||||
const nextAgain = event.target.closest("[data-next-again]");
|
||||
if (nextAgain) {
|
||||
armButton(nextAgain, "Nochmal starten?", async () => {
|
||||
nextAgain.disabled = true;
|
||||
try {
|
||||
await deskStartFile(nextAgain.dataset.nextAgain, true);
|
||||
} catch (error) {
|
||||
toast(error.message || String(error), "bad");
|
||||
} finally {
|
||||
nextAgain.disabled = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nextStart = event.target.closest("[data-next-start]");
|
||||
if (nextStart) {
|
||||
armButton(nextStart, "Starten?", async () => {
|
||||
nextStart.disabled = true;
|
||||
try {
|
||||
await deskStartFile(nextStart.dataset.nextStart, false);
|
||||
} catch (error) {
|
||||
toast(error.message || String(error), "bad");
|
||||
} finally {
|
||||
nextStart.disabled = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pill = event.target.closest("[data-desk-host]");
|
||||
if (pill) {
|
||||
openDesk(pill.dataset.deskHost);
|
||||
|
||||
@@ -111,7 +111,7 @@ function render() {
|
||||
if (job.direct) {
|
||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||
<td>${chipHtml("printing", "läuft")}</td>
|
||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span></td>
|
||||
<td>${job.file_id ? `<a class="cell-file" href="/dateien?file=${encodeURIComponent(job.file_id)}" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</a>` : `<span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>`}</td>
|
||||
<td><a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a></td>
|
||||
<td>—</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
@@ -125,16 +125,24 @@ function render() {
|
||||
const rep = repeatText(job);
|
||||
const tools = [];
|
||||
if (st === "waiting" && waitIdx > 0) {
|
||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>`);
|
||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="-1" title="Höher" aria-label="Höher">${icon("up")}</button>`);
|
||||
}
|
||||
if (st === "waiting" && waitIdx >= 0 && waitIdx < waiting.length - 1) {
|
||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>`);
|
||||
tools.push(`<button type="button" class="icon-btn" data-prio="${job.id}" data-dir="1" title="Tiefer" aria-label="Tiefer">${icon("down")}</button>`);
|
||||
}
|
||||
if (st === "error" && job.is_ablauf) {
|
||||
tools.push(`<button type="button" class="icon-btn" data-resume="${job.id}" title="Fortsetzen">${icon("play")}</button>`);
|
||||
tools.push(`<button type="button" class="icon-btn danger" data-dismiss="${job.id}" title="Verwerfen">${icon("x")}</button>`);
|
||||
tools.push(`<button type="button" class="ghost tiny-btn with-label" data-resume="${job.id}" title="Fortsetzen" aria-label="Fortsetzen">${icon("play")}<span class="act-label">Fortsetzen</span></button>`);
|
||||
tools.push(`<button type="button" class="ghost tiny-btn with-label danger" data-dismiss="${job.id}" title="Verwerfen" aria-label="Verwerfen">${icon("x")}<span class="act-label">Verwerfen</span></button>`);
|
||||
} else if (st === "error") {
|
||||
if (job.host) {
|
||||
tools.push(`<a class="ghost tiny-btn with-label" href="/#${encodeURIComponent(job.host)}" title="Zur Wand" aria-label="Zur Wand">${icon("printer")}<span class="act-label">Wand</span></a>`);
|
||||
}
|
||||
if (job.file_id) {
|
||||
tools.push(`<button type="button" class="ghost tiny-btn with-label" data-requeue="${job.id}" title="Erneut einreihen" aria-label="Erneut einreihen">${icon("refresh")}<span class="act-label">Nochmal</span></button>`);
|
||||
}
|
||||
tools.push(`<button type="button" class="ghost tiny-btn with-label danger" data-dismiss="${job.id}" title="Verwerfen" aria-label="Verwerfen">${icon("x")}<span class="act-label">Verwerfen</span></button>`);
|
||||
} else if (st === "waiting" || st === "running") {
|
||||
tools.push(`<button type="button" class="icon-btn danger" data-cancel="${job.id}" title="Abbrechen">${icon("x")}</button>`);
|
||||
tools.push(`<button type="button" class="ghost tiny-btn with-label danger" data-cancel="${job.id}" title="Abbrechen" aria-label="Abbrechen">${icon("x")}<span class="act-label">Abbrechen</span></button>`);
|
||||
}
|
||||
const phase = String(job.phase || "").toLowerCase();
|
||||
const starting = st === "running" && (phase === "starting" || phase === "await_print");
|
||||
@@ -142,7 +150,7 @@ function render() {
|
||||
const chipLabel = starting ? "startet" : (STATUS_LABEL[st] || st);
|
||||
return `<tr data-id="${job.id}">
|
||||
<td>${chipHtml(chipState, chipLabel)}</td>
|
||||
<td><span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${job.file_id ? `<a class="cell-file" href="/dateien?file=${encodeURIComponent(job.file_id)}" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</a>` : `<span class="cell-file" title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>`}${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${job.host ? `<a href="/#${encodeURIComponent(job.host)}">${escapeHtml(targetOf(job))}</a>` : escapeHtml(targetOf(job))}</td>
|
||||
<td>${steps || "—"}</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
@@ -217,6 +225,38 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const requeue = event.target.closest("[data-requeue]");
|
||||
if (requeue) {
|
||||
const job = jobs.find((j) => j.id === Number(requeue.dataset.requeue));
|
||||
if (!job || !job.file_id) return;
|
||||
armButton(requeue, "Erneut einreihen?", async () => {
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, {
|
||||
method: "PATCH", json: { status: "waiting", undo: true },
|
||||
});
|
||||
toast("Wieder in der Warteschlange.");
|
||||
load();
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
} catch (error) {
|
||||
try {
|
||||
await api("/senden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
file_id: job.file_id,
|
||||
hosts: job.host ? [job.host] : [],
|
||||
confirm: true,
|
||||
when_free: true,
|
||||
ace_maps: job.host && (job.ace_map || []).length ? { [job.host]: job.ace_map } : {},
|
||||
bed_leveling: !!job.bed_leveling,
|
||||
},
|
||||
});
|
||||
toast("Erneut eingereiht.");
|
||||
load();
|
||||
} catch (err2) { toast(err2.message, "bad"); }
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const prio = event.target.closest("[data-prio]");
|
||||
if (prio) {
|
||||
const id = Number(prio.dataset.prio);
|
||||
|
||||
Reference in new issue
Block a user