Files
grotti/web/templates/material.html
T
Dominic Gundel 4ddffbbeb1 Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
2026-10-18 14:58:32 +02:00

998 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Material</h1>
<p class="tiny">Spulen am Drucker und im Regal. Sorten sind die Profile dazu.</p>
</div>
<div class="page-head-side">
<label class="scan-inline" for="scan-code">
<svg class="ico" aria-hidden="true"><use href="#i-scan"/></svg>
<input id="scan-code" autocomplete="off" placeholder="Code oder Charge · Enter" title="USB-Scanner oder Charge tippen">
</label>
<p id="scan-note" class="scan-note" hidden></p>
</div>
</div>
<section class="card bedarf-card" id="bedarf-card" hidden>
<div class="lib-head">
<h2 id="bedarf">Bedarf aus Warteschlange</h2>
<span class="count" id="bedarf-short-count">0</span>
</div>
<p class="hint" id="bedarf-hint">Fehlmenge = Queue-Gewicht minus Spulenrest (gleiche Sorte/Farbe).</p>
<div class="table-wrap">
<table>
<thead><tr><th>Sorte</th><th>Farbe</th><th class="num">Queue</th><th class="num">Rest</th><th class="num">Fehlt</th><th class="num">Jobs</th></tr></thead>
<tbody id="bedarf-rows"></tbody>
</table>
</div>
</section>
<div class="tabs mat-tabs" role="tablist">
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
</div>
<div id="view-spools">
<div class="toolbar page-tools mat-tools">
<div class="fchips" id="spool-filter" role="group" aria-label="Ort">
<button type="button" class="fchip on" data-filter="all">Alle</button>
<button type="button" class="fchip" data-filter="loaded">Am Drucker</button>
<button type="button" class="fchip" data-filter="shelf">Regal</button>
<button type="button" class="fchip" data-filter="low">Wenig Rest</button>
</div>
<div class="search-wrap">
<svg class="ico"><use href="#i-search"/></svg>
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
</div>
<div class="tools-end"><button type="button" id="spool-new" class="btn-primary"><svg class="ico"><use href="#i-plus"/></svg>Neue Spule</button></div>
</div>
<p id="spool-list-note" class="tiny"></p>
<div class="split mat-split is-idle" id="spool-layout">
<section class="card table-wrap mat-list">
<div class="card-head lib-head">
<svg class="ico head-ico"><use href="#i-spool"/></svg>
<h2>Spulen</h2>
<span class="count-pill" id="spool-count">0</span>
</div>
<table>
<thead><tr><th>Spule</th><th>Charge</th><th>Sorte</th><th>Farbe</th><th>Ort</th><th class="num">Rest</th></tr></thead>
<tbody id="spool-rows"></tbody>
</table>
</section>
<section class="card mat-form" id="spool-panel" hidden>
<div class="mat-form-head">
<h2 id="spool-title">Neue Spule</h2>
<button type="button" class="icon-btn" id="spool-close" title="Schließen" aria-label="Schließen"></button>
</div>
<form id="spool-form">
<input id="spool-id" type="hidden">
<div class="field">
<label for="spool-material">Sorte <span class="tip" title="Profil mit Dichte, Preis und Volumen. Verwalten unter Reiter Sorten."><svg class="ico"><use href="#i-info"/></svg></span></label>
<select id="spool-material"></select>
<p id="spool-hint" class="field-help"></p>
</div>
<div class="field">
<label for="spool-name">Name</label>
<input id="spool-name" required>
</div>
<div class="grid-2">
<div class="field">
<label for="spool-printer">Drucker</label>
<select id="spool-printer"></select>
</div>
<div class="field">
<label for="spool-slot">ACE-Platz <span class="tip" title="ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal."><svg class="ico"><use href="#i-info"/></svg></span></label>
<select id="spool-slot">
<option value="">nicht im ACE</option>
<option value="1">ACE 1 · Platz 1</option>
<option value="2">ACE 1 · Platz 2</option>
<option value="3">ACE 1 · Platz 3</option>
<option value="4">ACE 1 · Platz 4</option>
<option value="5">ACE 2 · Platz 1</option>
<option value="6">ACE 2 · Platz 2</option>
<option value="7">ACE 2 · Platz 3</option>
<option value="8">ACE 2 · Platz 4</option>
</select>
</div>
<div class="field"><label for="spool-left">Rest gramm</label><input id="spool-left" type="number" step="0.1" value="1000"></div>
</div>
<details class="mat-color" id="color-box">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Farbe</span>
<span class="tiny" id="color-sum">noch keine</span>
</summary>
<div class="mat-color-body">
<div id="color-picked" class="picked"></div>
<div id="color-solids" class="color-grid"></div>
<div id="color-multi" class="color-grid"></div>
<label class="mix check"><input id="color-mix" type="checkbox"> Eigene Mischung</label>
</div>
</details>
<details class="mat-more" id="spool-more">
<summary>
<svg class="ico caret"><use href="#i-down"/></svg>
<span class="grow">Weitere Angaben</span>
</summary>
<div class="mat-more-body">
<div class="grid-2">
<div class="field"><label for="spool-brand">Marke</label><input id="spool-brand"></div>
<div class="field"><label for="spool-type">Typ</label><input id="spool-type" placeholder="PLA"></div>
<div class="field">
<label for="spool-chargen">Grotti-Charge</label>
<input id="spool-chargen" readonly placeholder="wird vergeben">
</div>
<div class="field">
<label for="spool-hersteller">Hersteller-Charge</label>
<input id="spool-hersteller" placeholder="vom Rollenetikett">
</div>
<div class="field"><label for="spool-price">€ pro kg</label><input id="spool-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="spool-density">Dichte g/cm³</label><input id="spool-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="spool-vol">Volumen mm³/s</label><input id="spool-vol" type="number" min="0.1" step="0.1" value="12"></div>
</div>
</div>
</details>
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="spool-shelf" hidden>Ins Regal</button>
<button type="button" class="ghost" id="spool-label" hidden>Etikett</button>
<button type="button" class="warn" id="spool-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<div id="spool-akte" class="spool-akte" hidden>
<h3>Akte</h3>
<div class="kpi-row akte-kpis" id="akte-kpis"></div>
<div class="akte-runs" id="akte-runs"></div>
</div>
<p id="spool-note"></p>
</section>
</div>
</div>
<div id="view-sorten" hidden>
<div class="page-tools mat-tools">
<input id="sorten-search" type="search" placeholder="Sorte suchen…" autocomplete="off">
<button type="button" id="sorten-new"><svg class="ico"><use href="#i-layers"/></svg>Neue Sorte</button>
</div>
<p id="sorten-list-note" class="tiny"></p>
<div class="split mat-split is-idle" id="sorten-layout">
<section class="card table-wrap mat-list">
<div class="lib-head">
<h2>Sorten</h2>
<span class="count" id="sorten-count">0</span>
</div>
<table>
<thead>
<tr>
<th>Sorte</th>
<th>Typ</th>
<th>Drucker</th>
<th class="num">Dichte</th>
<th class="num">mm³/s</th>
<th class="num">€/kg</th>
</tr>
</thead>
<tbody id="sorten-rows"></tbody>
</table>
</section>
<section class="card mat-form" id="sorten-panel" hidden>
<div class="mat-form-head">
<h2 id="sorten-title">Neue Sorte</h2>
<button type="button" class="icon-btn" id="sorten-close" title="Schließen"></button>
</div>
<form id="sorten-form">
<input id="sorten-id" type="hidden">
<div class="field">
<label for="sorten-name">Name</label>
<input id="sorten-name" required placeholder="z. B. Sunlu PLA+">
</div>
<div class="grid-2">
<div class="field"><label for="sorten-brand">Marke</label><input id="sorten-brand"></div>
<div class="field"><label for="sorten-type">Typ</label><input id="sorten-type" placeholder="PLA"></div>
</div>
<div class="field">
<label for="sorten-machines">Maschinen</label>
<select id="sorten-machines">
<option value="beide">beide</option>
<option value="kobra_3">nur Kobra 3</option>
<option value="kobra_s1">nur Kobra S1</option>
</select>
</div>
<div class="grid-2">
<div class="field"><label for="sorten-density">Dichte g/cm³</label><input id="sorten-density" type="number" min="0.1" step="0.01" value="1.24"></div>
<div class="field"><label for="sorten-price">€ pro kg</label><input id="sorten-price" type="number" min="0" step="0.01" value="0"></div>
<div class="field"><label for="sorten-vol">Volumen mm³/s</label><input id="sorten-vol" type="number" min="0.1" step="0.1" value="12"></div>
<div class="field"><label for="sorten-vol-s1">Volumen S1 <span class="tip" title="Leer = wie oben. Nur setzen, wenn S1 anders ist."><svg class="ico"><use href="#i-info"/></svg></span></label><input id="sorten-vol-s1" type="number" min="0.1" step="0.1" placeholder="wie oben"></div>
</div>
<div class="field">
<label for="sorten-note">Notiz</label>
<input id="sorten-note" placeholder="optional">
</div>
<div class="actions">
<button type="submit" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="sorten-use">Spule damit anlegen</button>
<button type="button" class="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
</div>
</form>
<p id="sorten-note-msg"></p>
</section>
</div>
</div>
{% endblock %}
{% block script %}
<script>
let spools = [];
let printers = [];
let materials = [];
let palette = { solids: [], multi: [] };
let picked = [];
let colorLabel = "";
let autoName = "";
let spoolFilter = "all";
let spoolSearch = "";
let sortenSearch = "";
let view = "spools";
let spoolPanelOpen = false;
let sortenPanelOpen = false;
document.getElementById("spool-close").innerHTML = icon("x");
document.getElementById("sorten-close").innerHTML = icon("x");
function dots(colors) {
const wrap = document.createElement("span");
wrap.className = "dots";
(colors || []).forEach((color) => {
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = color.hex;
dot.title = color.name;
wrap.appendChild(dot);
});
return wrap;
}
function slotLabel(slot) {
if (!slot) return "";
if (slot <= 4) return "ACE 1 · " + slot;
return "ACE 2 · " + (slot - 4);
}
function printerName(host) {
return (printers.find((p) => p.host === host) || {}).name || host || "";
}
function ortLabel(spool) {
if (!spool.printer_host) return "Regal";
const name = printerName(spool.printer_host);
if (spool.ace_slot) return name + " · " + slotLabel(spool.ace_slot);
return name + " · extern";
}
function selectedMaterial() {
const id = document.getElementById("spool-material").value;
return materials.find((row) => String(row.id) === id) || null;
}
function volumeOf(material) {
const host = document.getElementById("spool-printer").value;
const printer = printers.find((row) => row.host === host);
if (printer && printer.family === "kobra_s1" && material.volumetric_s1 != null) return material.volumetric_s1;
return material.volumetric;
}
function showHint() {
const material = selectedMaterial();
const node = document.getElementById("spool-hint");
if (!material) {
node.textContent = "";
return;
}
const host = document.getElementById("spool-printer").value;
const printer = printers.find((row) => row.host === host);
const parts = [];
if (printer && material.machines !== "beide" && material.machines !== printer.family) {
parts.push("Diese Sorte läuft bei dir " + material.machines_label + ".");
} else if (material.machines !== "beide") {
parts.push(material.machines_label.charAt(0).toUpperCase() + material.machines_label.slice(1) + ".");
}
if (material.note) parts.push(material.note);
node.textContent = parts.join(" ");
}
function applyMaterial(material, rename) {
if (!material) return;
document.getElementById("spool-material").value = String(material.id);
document.getElementById("spool-brand").value = material.brand;
document.getElementById("spool-type").value = material.material_type;
document.getElementById("spool-density").value = material.density;
document.getElementById("spool-price").value = material.price_per_kg;
document.getElementById("spool-vol").value = volumeOf(material);
const name = document.getElementById("spool-name");
if (rename && (!name.value || name.value === autoName)) {
autoName = material.name;
name.value = material.name;
}
showHint();
}
function colorSummary() {
if (!picked.length) return "noch keine";
return colorLabel || picked.map((c) => c.name).join(" / ");
}
function renderPicked() {
const node = document.getElementById("color-picked");
node.innerHTML = "";
const sum = document.getElementById("color-sum");
if (!picked.length) {
node.textContent = "Farbe tippen — einfarbig oder Mehrton.";
sum.textContent = "noch keine";
return;
}
node.appendChild(dots(picked));
const label = document.createElement("span");
label.textContent = colorLabel || picked.map((color) => color.name).join(" / ");
node.appendChild(label);
sum.textContent = colorSummary();
}
function renderPalette() {
const mix = document.getElementById("color-mix").checked;
const solids = document.getElementById("color-solids");
const multi = document.getElementById("color-multi");
solids.innerHTML = "";
multi.innerHTML = "";
palette.solids.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "swatch";
const on = mix
? picked.some((color) => color.hex === item.colors[0].hex)
: picked.length === 1 && picked[0].hex === item.colors[0].hex && colorLabel === item.name;
if (on) button.classList.add("on");
button.appendChild(dots(item.colors));
button.appendChild(document.createTextNode(item.name));
button.addEventListener("click", () => chooseSolid(item));
solids.appendChild(button);
});
palette.multi.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "swatch";
if (colorLabel === item.name) button.classList.add("on");
button.appendChild(dots(item.colors));
button.appendChild(document.createTextNode(item.name));
button.addEventListener("click", () => chooseMulti(item));
multi.appendChild(button);
});
renderPicked();
}
function chooseSolid(item) {
const color = item.colors[0];
if (document.getElementById("color-mix").checked) {
const index = picked.findIndex((row) => row.hex === color.hex);
if (index >= 0) picked.splice(index, 1);
else if (picked.length < 8) picked.push(color);
colorLabel = picked.map((row) => row.name).join(" / ");
} else {
picked = [color];
colorLabel = item.name;
}
renderPalette();
}
function chooseMulti(item) {
picked = item.colors.map((color) => ({ name: color.name, hex: color.hex }));
colorLabel = item.name;
document.getElementById("color-mix").checked = false;
renderPalette();
}
function setView(next) {
view = next;
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
btn.classList.toggle("on", btn.dataset.view === view);
});
document.getElementById("view-spools").hidden = view !== "spools";
document.getElementById("view-sorten").hidden = view !== "sorten";
}
function openSpoolPanel() {
spoolPanelOpen = true;
document.getElementById("spool-panel").hidden = false;
document.getElementById("spool-layout").classList.remove("is-idle");
}
function closeSpoolPanel() {
spoolPanelOpen = false;
document.getElementById("spool-panel").hidden = true;
document.getElementById("spool-layout").classList.add("is-idle");
document.getElementById("spool-id").value = "";
document.getElementById("spool-note").textContent = "";
renderSpools();
}
function openSortenPanel() {
sortenPanelOpen = true;
document.getElementById("sorten-panel").hidden = false;
document.getElementById("sorten-layout").classList.remove("is-idle");
}
function closeSortenPanel() {
sortenPanelOpen = false;
document.getElementById("sorten-panel").hidden = true;
document.getElementById("sorten-layout").classList.add("is-idle");
document.getElementById("sorten-id").value = "";
document.getElementById("sorten-note-msg").textContent = "";
renderSorten();
}
function payload() {
return {
name: document.getElementById("spool-name").value,
brand: document.getElementById("spool-brand").value,
material_type: document.getElementById("spool-type").value,
material_id: document.getElementById("spool-material").value || null,
hersteller_charge: document.getElementById("spool-hersteller").value,
color: colorLabel,
colors: picked,
printer_host: document.getElementById("spool-printer").value,
ace_slot: document.getElementById("spool-slot").value || null,
price_per_kg: Number(document.getElementById("spool-price").value),
remaining_g: Number(document.getElementById("spool-left").value),
density: Number(document.getElementById("spool-density").value),
volumetric: Number(document.getElementById("spool-vol").value),
};
}
const akteStates = {
completed: "fertig", cancelled: "abgebrochen", error: "Fehler",
in_progress: "druckt", printing: "druckt", paused: "pausiert",
interrupted: "unterbrochen",
};
function akteWhen(iso) {
if (!iso) return "—";
const text = formatDateTime(iso, { year: undefined });
return text === "—" ? "—" : text.replace(/,\s*/, " ");
}
function akteFile(name) {
const base = (name || "").split("/").pop() || "";
if (!base) return "Druck";
if (base.length <= 42) return base;
return base.slice(0, 39) + "…";
}
function akteStatus(run) {
if (run.verdict === "ausschuss") return "Ausschuss";
if (run.verdict === "gut") return "gut";
return akteStates[run.state] || run.state || "";
}
function akteKpi(label, value) {
return '<div class="kpi"><div class="kpi-val">' + escapeHtml(value) + '</div><div class="kpi-label">' + escapeHtml(label) + "</div></div>";
}
async function loadAkte(spoolId) {
const box = document.getElementById("spool-akte");
if (!spoolId) {
box.hidden = true;
return;
}
try {
const data = await api("/spulen/" + spoolId + "/akte");
const stats = data.stats || {};
const fillPct = Math.round(Number(stats.fill_pct) || 0);
let leer = "—";
if (stats.empty_in_days == null) leer = "kein Verbrauch";
else if (stats.empty_in_days <= 0) leer = "leer";
else leer = "~" + Math.round(stats.empty_in_days) + " Tage";
document.getElementById("akte-kpis").innerHTML =
akteKpi("Verdruckt", grams(stats.total_g))
+ akteKpi("Ausschuss", grams(stats.scrap_g))
+ akteKpi("Kosten", euro(stats.total_cost))
+ akteKpi("Füllstand", fillPct + " %")
+ akteKpi("Leer-in", leer);
const list = document.getElementById("akte-runs");
const runs = data.runs || [];
if (!runs.length) {
list.innerHTML = '<p class="tiny">Noch kein Druck auf dieser Spule.</p>';
} else {
list.innerHTML = runs.slice(0, 12).map((run) => {
const href = "/protokoll/" + run.id;
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 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>"
+ charge
+ file
+ '<span class="akte-g">' + escapeHtml(gramsTxt) + "</span>"
+ '<span class="akte-state tiny">' + escapeHtml(akteStatus(run)) + "</span>"
+ "</div>";
}).join("");
}
box.hidden = false;
} catch (error) {
box.hidden = true;
}
}
function fill(spool) {
openSpoolPanel();
document.getElementById("spool-id").value = spool ? spool.id : "";
document.getElementById("spool-name").value = spool ? spool.name : "";
document.getElementById("spool-brand").value = spool ? spool.brand : "";
document.getElementById("spool-type").value = spool ? spool.material_type : "";
document.getElementById("spool-chargen").value = spool ? (spool.chargen_nr || spool.code || "") : "";
document.getElementById("spool-hersteller").value = spool ? (spool.hersteller_charge || "") : "";
document.getElementById("spool-printer").value = spool ? spool.printer_host : "";
document.getElementById("spool-slot").value = spool && spool.ace_slot ? String(spool.ace_slot) : "";
document.getElementById("spool-price").value = spool ? spool.price_per_kg : 0;
document.getElementById("spool-left").value = spool
? Math.round((Number(spool.remaining_g) || 0) * 10) / 10
: 1000;
document.getElementById("spool-density").value = spool ? spool.density : 1.24;
document.getElementById("spool-vol").value = spool ? spool.volumetric : 12;
document.getElementById("spool-material").value = spool && spool.material_id ? String(spool.material_id) : "";
picked = spool && spool.colors ? spool.colors.map((color) => ({ name: color.name, hex: color.hex })) : [];
colorLabel = spool ? spool.color : "";
document.getElementById("color-mix").checked = false;
autoName = "";
document.getElementById("spool-title").textContent = spool ? spool.name : "Neue Spule";
document.getElementById("spool-delete").hidden = !spool;
document.getElementById("spool-label").hidden = !spool;
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
document.getElementById("color-box").open = !spool || !picked.length;
showHint();
renderPalette();
renderSpools();
loadAkte(spool ? spool.id : null);
document.getElementById("spool-panel").scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function fillSorten(material) {
openSortenPanel();
document.getElementById("sorten-id").value = material ? material.id : "";
document.getElementById("sorten-name").value = material ? material.name : "";
document.getElementById("sorten-brand").value = material ? material.brand : "";
document.getElementById("sorten-type").value = material ? material.material_type : "PLA";
document.getElementById("sorten-machines").value = material ? material.machines : "beide";
document.getElementById("sorten-density").value = material ? material.density : 1.24;
document.getElementById("sorten-price").value = material ? material.price_per_kg : 0;
document.getElementById("sorten-vol").value = material ? material.volumetric : 12;
document.getElementById("sorten-vol-s1").value =
material && material.volumetric_s1 != null ? material.volumetric_s1 : "";
document.getElementById("sorten-note").value = material ? (material.note || "") : "";
document.getElementById("sorten-title").textContent = material ? material.name : "Neue Sorte";
document.getElementById("sorten-delete").hidden = !material;
document.getElementById("sorten-use").hidden = !material;
renderSorten();
}
function shownSpools() {
const q = spoolSearch.trim().toLowerCase();
return spools.filter((spool) => {
if (spoolFilter === "loaded" && !spool.printer_host) return false;
if (spoolFilter === "shelf" && spool.printer_host) return false;
if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false;
if (!q) return true;
const hay = [
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
spool.brand, ortLabel(spool),
].join(" ").toLowerCase();
return hay.includes(q);
});
}
function shownSorten() {
const q = sortenSearch.trim().toLowerCase();
if (!q) return materials;
return materials.filter((m) =>
[m.name, m.brand, m.material_type, m.machines_label, m.note].join(" ").toLowerCase().includes(q)
);
}
function fillSelects() {
const select = document.getElementById("spool-printer");
const current = select.value;
select.innerHTML = '<option value="">Regal</option>';
printers.forEach((printer) => {
const option = document.createElement("option");
option.value = printer.host;
option.textContent = printer.name;
select.appendChild(option);
});
select.value = current;
const materialSelect = document.getElementById("spool-material");
const currentMaterial = materialSelect.value;
materialSelect.innerHTML = '<option value="">Sorte wählen</option>';
materials.forEach((material) => {
const option = document.createElement("option");
option.value = material.id;
option.textContent = material.name;
materialSelect.appendChild(option);
});
materialSelect.value = currentMaterial;
}
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
});
fillSelects();
const body = document.getElementById("spool-rows");
body.innerHTML = "";
const shown = shownSpools();
document.getElementById("spool-count").textContent = String(shown.length);
const note = document.getElementById("spool-list-note");
if (!spools.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("spool") + '<p>Noch keine Spule.</p><p class="tiny">„Neue Spule“ anlegen oder Code scannen.</p></div></td></tr>';
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === spools.length
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + spools.length;
const ordered = [...shown].sort((a, b) => {
const ah = a.printer_host || "zzz";
const bh = b.printer_host || "zzz";
return ah.localeCompare(bh, "de") || (Number(a.ace_slot) || 0) - (Number(b.ace_slot) || 0) || a.name.localeCompare(b.name, "de");
});
let lastPlace = null;
ordered.forEach((spool) => {
const group = spool.printer_host ? printerName(spool.printer_host) : "Regal";
if (group !== lastPlace) {
lastPlace = group;
const head = document.createElement("tr");
head.className = "mat-group";
head.innerHTML = `<td colspan="6">${escapeHtml(group)}</td>`;
body.appendChild(head);
}
const tr = document.createElement("tr");
tr.className = "pick";
if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on");
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>";
tr.children[0].textContent = spool.name;
tr.children[1].textContent = spool.chargen_nr || spool.code || "";
tr.children[1].classList.add("nowrap");
tr.children[2].textContent = spool.material_type;
const colorCell = tr.children[3];
if (spool.colors && spool.colors.length) colorCell.appendChild(dots(spool.colors));
const caption = document.createElement("span");
caption.className = "mat-color-cap";
caption.textContent = spool.color ? " " + spool.color : "";
colorCell.appendChild(caption);
tr.children[4].textContent = ortLabel(spool);
tr.children[4].classList.add("nowrap");
tr.children[4].title = ortLabel(spool);
const rest = Number(spool.remaining_g) || 0;
tr.children[5].textContent = grams(rest);
if (rest < 100) tr.children[5].classList.add("msg");
if (rest < 0) tr.children[5].title = "Überzogen — Rest korrigieren";
tr.addEventListener("click", () => fill(spool));
body.appendChild(tr);
});
}
function renderSorten() {
fillSelects();
const body = document.getElementById("sorten-rows");
body.innerHTML = "";
const shown = shownSorten();
document.getElementById("sorten-count").textContent = String(shown.length);
const note = document.getElementById("sorten-list-note");
if (!materials.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("layers") + '<p>Noch keine Sorte.</p></div></td></tr>';
return;
}
if (!shown.length) {
note.textContent = "";
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Sorte passt.</p></div></td></tr>';
return;
}
note.textContent = shown.length === 1 ? "1 Sorte" : shown.length + " Sorten";
shown.forEach((material) => {
const tr = document.createElement("tr");
tr.className = "pick";
if (sortenPanelOpen && String(material.id) === document.getElementById("sorten-id").value) tr.classList.add("on");
const speeds = material.volumetric_s1 != null && material.volumetric_s1 !== material.volumetric
? material.volumetric + " · S1 " + material.volumetric_s1
: String(material.volumetric);
tr.innerHTML = "<td></td><td></td><td class=\"nowrap\"></td><td class=\"num\"></td><td class=\"num\"></td><td class=\"num\"></td>";
tr.children[0].textContent = material.name;
tr.children[1].textContent = material.material_type;
tr.children[2].textContent = material.machines_label;
tr.children[3].textContent = String(material.density).replace(".", ",");
tr.children[4].textContent = speeds;
tr.children[5].textContent = euro(material.price_per_kg);
tr.addEventListener("click", () => fillSorten(material));
body.appendChild(tr);
});
}
function render() {
renderSpools();
renderSorten();
}
function renderBedarf(data) {
const card = document.getElementById("bedarf-card");
const shortfall = (data && data.shortfall) || [];
const needed = (data && data.needed) || [];
if (!needed.length && !shortfall.length) {
card.hidden = true;
return;
}
card.hidden = false;
document.getElementById("bedarf-short-count").textContent = shortfall.length
? shortfall.length + " Fehl"
: "ok";
document.getElementById("bedarf-hint").textContent = shortfall.length
? shortfall.length + " Sorten reichen nicht für die wartende Queue."
: "Queue hat Bedarf — Spulenrest reicht.";
const rows = (shortfall.length ? shortfall : needed).map((item) => {
const missing = item.shortfall_g != null ? grams(item.shortfall_g) : "—";
const rest = item.remaining_g != null
? grams(item.remaining_g)
: "—";
return `<tr class="${item.shortfall_g > 0 ? "warn" : ""}">
<td>${escapeHtml(item.material_type || "—")}</td>
<td>${escapeHtml(item.color || "—")}</td>
<td class="num">${grams(item.weight_g)}</td>
<td class="num">${rest}</td>
<td class="num">${missing}</td>
<td class="num">${item.jobs || 0}</td>
</tr>`;
}).join("");
document.getElementById("bedarf-rows").innerHTML = rows
|| '<tr><td class="empty" colspan="6">Kein Queue-Bedarf.</td></tr>';
}
async function load() {
const [spoolData, printerData, materialData, paletteData, bedarfData] = await Promise.all([
api("/spulen"),
api("/drucker?live=0"),
api("/materialien"),
api("/farben"),
api("/bedarf").catch(() => ({ needed: [], shortfall: [] })),
]);
spools = spoolData;
printers = printerData;
materials = materialData;
palette = paletteData;
render();
renderPalette();
renderBedarf(bedarfData);
}
window.onBedarfChanged = renderBedarf;
function setScanNote(text, ok) {
const note = document.getElementById("scan-note");
if (!text) {
note.hidden = true;
note.textContent = "";
return;
}
note.hidden = false;
note.textContent = text;
note.className = "scan-note " + (ok ? "ok" : "msg");
}
document.querySelectorAll(".mat-tabs [data-view]").forEach((btn) => {
btn.addEventListener("click", () => setView(btn.dataset.view));
});
document.getElementById("spool-filter").addEventListener("click", (event) => {
const btn = event.target.closest("[data-filter]");
if (!btn) return;
spoolFilter = btn.dataset.filter || "all";
renderSpools();
});
document.getElementById("spool-search").addEventListener("input", (event) => {
spoolSearch = event.target.value || "";
renderSpools();
});
document.getElementById("sorten-search").addEventListener("input", (event) => {
sortenSearch = event.target.value || "";
renderSorten();
});
document.getElementById("spool-close").addEventListener("click", closeSpoolPanel);
document.getElementById("sorten-close").addEventListener("click", closeSortenPanel);
document.getElementById("spool-material").addEventListener("change", () => applyMaterial(selectedMaterial(), true));
document.getElementById("spool-printer").addEventListener("change", () => {
const material = selectedMaterial();
if (material) document.getElementById("spool-vol").value = volumeOf(material);
showHint();
});
document.getElementById("color-mix").addEventListener("change", renderPalette);
document.getElementById("spool-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("spool-id").value;
const data = payload();
const host = data.printer_host;
const slot = data.ace_slot;
try {
const base = { ...data, printer_host: "", ace_slot: null };
let saved = id
? await api("/spulen/" + id, { method: "PATCH", json: base })
: await api("/spulen", { method: "POST", json: base });
if (host) {
saved = await api("/spulen/" + saved.id + "/einlegen", {
method: "POST",
json: { host, ace_slot: slot },
});
}
toast("Spule gemerkt.");
document.getElementById("spool-note").textContent = "Gemerkt.";
document.getElementById("spool-note").className = "ok";
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-new").addEventListener("click", () => {
fill(null);
document.getElementById("spool-material").focus();
});
document.getElementById("spool-shelf").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const saved = await api("/spulen/" + id, { method: "PATCH", json: { printer_host: "", ace_slot: null } });
toast("Spule liegt im Regal.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-label").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (id) window.open("/etikett/" + id, "_blank", "noopener");
});
document.getElementById("spool-delete").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
armButton(document.getElementById("spool-delete"), "Löschen?", async () => {
await api("/spulen/" + id, { method: "DELETE" });
toast("Spule gelöscht.");
await load();
closeSpoolPanel();
});
});
document.getElementById("sorten-new").addEventListener("click", () => {
fillSorten(null);
document.getElementById("sorten-name").focus();
});
document.getElementById("sorten-form").addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("sorten-id").value;
const volS1 = document.getElementById("sorten-vol-s1").value;
const data = {
name: document.getElementById("sorten-name").value.trim(),
brand: document.getElementById("sorten-brand").value.trim(),
material_type: document.getElementById("sorten-type").value.trim() || "PLA",
machines: document.getElementById("sorten-machines").value,
density: Number(document.getElementById("sorten-density").value) || 1.24,
price_per_kg: Number(document.getElementById("sorten-price").value) || 0,
volumetric: Number(document.getElementById("sorten-vol").value) || 12,
volumetric_s1: volS1 === "" ? null : Number(volS1),
note: document.getElementById("sorten-note").value.trim(),
};
try {
const saved = id
? await api("/materialien/" + id, { method: "PATCH", json: data })
: await api("/materialien", { method: "POST", json: data });
toast("Sorte gemerkt.");
showNote("sorten-note-msg", null, "Gemerkt.");
await load();
fillSorten(saved);
} catch (error) {
showNote("sorten-note-msg", error);
}
});
document.getElementById("sorten-use").addEventListener("click", () => {
const id = document.getElementById("sorten-id").value;
const material = materials.find((row) => String(row.id) === id);
if (!material) return;
setView("spools");
fill(null);
applyMaterial(material, true);
document.getElementById("color-box").open = true;
});
document.getElementById("sorten-delete").addEventListener("click", () => {
const id = document.getElementById("sorten-id").value;
if (!id) return;
armButton(document.getElementById("sorten-delete"), "Löschen?", async () => {
try {
await api("/materialien/" + id, { method: "DELETE" });
toast("Sorte gelöscht.");
await load();
closeSortenPanel();
} catch (error) {
showNote("sorten-note-msg", error);
}
});
});
async function openByCode(raw) {
const code = (raw || "").trim();
if (!code) return;
try {
let spool = null;
if (/^\d+$/.test(code)) {
spool = spools.find((row) => String(row.id) === code) || null;
}
if (!spool && code.includes("spool=")) {
const match = code.match(/[?&]spool=(\d+)/);
if (match) spool = spools.find((row) => String(row.id) === match[1]) || null;
}
if (!spool) {
const found = await api("/spulen/by-code/" + encodeURIComponent(code));
if (found && found.kind === "lauf" && found.redirect) {
location.href = found.redirect;
return;
}
spool = found;
}
setView("spools");
fill(spool);
setScanNote("Spule " + (spool.chargen_nr || spool.name) + " geöffnet.", true);
document.getElementById("scan-code").select();
} catch (error) {
setScanNote(error.message || String(error), false);
}
}
document.getElementById("scan-code").addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
openByCode(event.target.value);
}
});
load().then(() => {
closeSpoolPanel();
closeSortenPanel();
const params = new URLSearchParams(location.search);
const spoolId = params.get("spool");
if (spoolId) {
const spool = spools.find((row) => String(row.id) === spoolId);
if (spool) fill(spool);
else openByCode(spoolId);
}
}).catch((error) => showNote("spool-note", error));
</script>
{% endblock %}