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:
Dominic Gundel committed 2026-10-25 08:24:24 +01:00
1 parent d1eb87ba25
commit 2b1e36634a
53 files changed
+4254 -636

No files matched your search

+345 -56
View File
@@ -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 %}