Files
grotti/web/templates/material.html
T
Dominic Gundel db6a85a703 Grotti 3.7.0: Treiber für Kobra 4 und Marlin, smartere Dateien-Auswahl und einstellbare Etiketten.
Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
2026-11-02 14:03:57 +01:00

1192 lines
48 KiB
HTML
Raw Permalink 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>
<button type="button" class="fchip" data-filter="archive">Archiv</button>
</div>
<div class="mat-extra-filters">
<select id="spool-filter-type" title="Sorte"><option value="">Alle Sorten</option></select>
<select id="spool-filter-color" title="Farbe"><option value="">Alle Farben</option></select>
</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>
<div class="pager" id="spool-pager" hidden></div>
</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-empty" hidden title="Rest auf 0, Regal, archivieren">Leer</button>
<button type="button" class="ghost" id="spool-archive" hidden>Archivieren</button>
<button type="button" class="ghost" id="spool-restore" hidden>Wiederherstellen</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 spoolTypeFilter = "";
let spoolColorFilter = "";
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 printerFamily(host) {
return (printers.find((p) => p.host === host) || {}).family || "";
}
function slotLabel(slot, host) {
if (!slot) return "";
const family = printerFamily(host);
if (family === "tenlog") return "Kopf " + slot;
if (family === "anycubic_i3") return "am Drucker";
if (slot <= 4) return "ACE 1 · " + slot;
return "ACE 2 · " + (slot - 4);
}
function fillSlotOptions(host, keep) {
const select = document.getElementById("spool-slot");
const label = document.querySelector('label[for="spool-slot"]');
const current = keep != null ? String(keep) : select.value;
const family = printerFamily(host);
let options = [['', 'nicht im ACE']];
let tip = "ACE 1 = Plätze 1–4, ACE 2 = 5–8. Leer = externe Spule am Drucker. Ohne Drucker = Regal.";
if (!host) {
options = [['', 'Regal — kein Platz']];
tip = "Ohne Drucker liegt die Rolle im Regal.";
} else if (family === "tenlog") {
options = [['', 'kein Kopf'], ['1', 'Kopf 1'], ['2', 'Kopf 2']];
tip = "Tenlog: Kopf 1 oder Kopf 2. Leer = Rolle am Drucker ohne festen Kopf.";
} else if (family === "anycubic_i3") {
options = [['', 'am Drucker']];
tip = "Anycubic i3 hat keinen ACE — die Rolle liegt am Drucker.";
} else if (["kobra_3", "kobra_s1", "kobra_4"].includes(family)) {
options = [
['', 'nicht im ACE'],
['1', 'ACE 1 · Platz 1'], ['2', 'ACE 1 · Platz 2'], ['3', 'ACE 1 · Platz 3'], ['4', 'ACE 1 · Platz 4'],
['5', 'ACE 2 · Platz 1'], ['6', 'ACE 2 · Platz 2'], ['7', 'ACE 2 · Platz 3'], ['8', 'ACE 2 · Platz 4'],
];
} else {
options = [['', 'am Drucker']];
tip = "Platz nur bei ACE-Druckern.";
}
select.innerHTML = options.map(([value, text]) =>
`<option value="${escapeHtml(value)}">${escapeHtml(text)}</option>`
).join("");
if ([...select.options].some((o) => o.value === current)) select.value = current;
else select.value = "";
if (label) {
const tipNode = label.querySelector(".tip");
if (tipNode) tipNode.title = tip;
const title = family === "tenlog" ? "Kopf" : family === "anycubic_i3" ? "Lage" : "ACE-Platz";
label.childNodes[0].textContent = title + " ";
}
}
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, spool.printer_host);
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 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>"
+ 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 : "";
fillSlotOptions(spool ? spool.printer_host : "", 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("spool-empty").hidden = !spool || spool.archived;
document.getElementById("spool-archive").hidden = !spool || spool.archived;
document.getElementById("spool-restore").hidden = !spool || !spool.archived;
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 isSpoolArchived(spool) {
return !!(spool && (spool.archived || spool.archived_at));
}
function isSpoolEmpty(spool) {
return (Number(spool.remaining_g) || 0) <= 0.5;
}
function shownSpools() {
const q = spoolSearch.trim().toLowerCase();
const type = spoolTypeFilter.trim().toLowerCase();
const color = spoolColorFilter.trim().toLowerCase();
return spools.filter((spool) => {
const archived = isSpoolArchived(spool) || isSpoolEmpty(spool);
if (spoolFilter === "archive") {
if (!archived) return false;
} else if (archived) {
return false;
}
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 (type && (spool.material_type || "").toLowerCase() !== type) return false;
if (color && (spool.color || "").toLowerCase() !== color) 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 fillSpoolMetaFilters() {
const typeSel = document.getElementById("spool-filter-type");
const colorSel = document.getElementById("spool-filter-color");
if (!typeSel || !colorSel) return;
const types = [...new Set(spools.map((s) => (s.material_type || "").trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, "de"));
const colors = [...new Set(spools.map((s) => (s.color || "").trim()).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, "de"));
const curType = spoolTypeFilter;
const curColor = spoolColorFilter;
typeSel.innerHTML = '<option value="">Alle Sorten</option>' + types.map((t) =>
`<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`
).join("");
colorSel.innerHTML = '<option value="">Alle Farben</option>' + colors.map((c) =>
`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
).join("");
typeSel.value = types.includes(curType) ? curType : "";
colorSel.value = colors.includes(curColor) ? curColor : "";
spoolTypeFilter = typeSel.value;
spoolColorFilter = colorSel.value;
}
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;
fillSlotOptions(select.value);
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;
}
let spoolPage = 1;
function renderSpools() {
document.querySelectorAll("#spool-filter .fchip").forEach((btn) => {
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
});
fillSelects();
fillSpoolMetaFilters();
const body = document.getElementById("spool-rows");
body.innerHTML = "";
const shown = shownSpools();
const activeCount = spools.filter((s) => !isSpoolArchived(s) && !isSpoolEmpty(s)).length;
document.getElementById("spool-count").textContent = String(
spoolFilter === "archive" ? shown.length : activeCount
);
const note = document.getElementById("spool-list-note");
const pager = document.getElementById("spool-pager");
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>';
renderPager(pager, null, () => {});
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>';
renderPager(pager, null, () => {});
return;
}
note.textContent = spoolFilter === "archive"
? (shown.length === 1 ? "1 archivierte Spule" : shown.length + " archivierte Spulen")
: shown.length === activeCount
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
: shown.length + " von " + activeCount;
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");
});
const paged = pageSlice(ordered, spoolPage, DEFAULT_PAGE_SIZE);
spoolPage = paged.page;
let lastPlace = null;
paged.items.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" + (isSpoolArchived(spool) || isSpoolEmpty(spool) ? " is-archived" : "");
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);
});
renderPager(pager, paged, (p) => {
spoolPage = p;
renderSpools();
});
}
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) => {
spoolPage = 1;
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 || "";
spoolPage = 1;
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", () => {
fillSlotOptions(document.getElementById("spool-printer").value);
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-empty").addEventListener("click", () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
armButton(document.getElementById("spool-empty"), "Als leer markieren?", async () => {
try {
const saved = await api("/spulen/" + id + "/leer", { method: "POST", json: {} });
toast("Spule als leer archiviert.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
});
document.getElementById("spool-archive").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const saved = await api("/spulen/" + id, {
method: "PATCH",
json: { archived: true, printer_host: "", ace_slot: null },
});
toast("Spule archiviert.");
await load();
fill(saved);
} catch (error) {
showNote("spool-note", error);
}
});
document.getElementById("spool-restore").addEventListener("click", async () => {
const id = document.getElementById("spool-id").value;
if (!id) return;
try {
const left = Number(document.getElementById("spool-left").value) || 0;
const saved = await api("/spulen/" + id, {
method: "PATCH",
json: { archived: false, remaining_g: left > 0.5 ? left : 1000 },
});
toast("Spule wiederhergestellt.");
spoolFilter = "all";
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("spool-filter-type")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolTypeFilter = event.target.value || "";
renderSpools();
});
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
spoolPage = 1;
spoolColorFilter = event.target.value || "";
renderSpools();
});
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("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);
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;
fillSlotOptions(hostHint, slotHint ? 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>
{% endblock %}