Grotti 3.3.0: Projekte mit Ordner, Datei-Löschen/Verschieben, Material-Archiv, UI-Polish.
This commit is contained in:
1 parent
4ddffbbeb1
commit
67a89d1794
45 files changed
+2856
-320
No files matched your search
+28
-16
@@ -29,7 +29,8 @@ function sourceLabel(source, estimate) {
|
||||
return source || "";
|
||||
}
|
||||
|
||||
const ENERGY_PART_COLORS = {
|
||||
const COST_PART_COLORS = {
|
||||
filament: "#0d9488",
|
||||
base: "#94a3b8",
|
||||
nozzle: "#ea580c",
|
||||
bed: "#2563eb",
|
||||
@@ -37,19 +38,20 @@ const ENERGY_PART_COLORS = {
|
||||
};
|
||||
|
||||
function kwhLabel(value) {
|
||||
if (value == null || value === "") return "—";
|
||||
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
|
||||
}
|
||||
|
||||
function energyPieSvg(parts) {
|
||||
const items = (parts || []).filter((p) => Number(p.kwh) > 0);
|
||||
const total = items.reduce((sum, p) => sum + Number(p.kwh || 0), 0);
|
||||
function costPieSvg(parts) {
|
||||
const items = (parts || []).filter((p) => Number(p.cost) > 0);
|
||||
const total = items.reduce((sum, p) => sum + Number(p.cost || 0), 0);
|
||||
if (!items.length || total <= 0) return "";
|
||||
const cx = 42;
|
||||
const cy = 42;
|
||||
const r = 36;
|
||||
let angle = -Math.PI / 2;
|
||||
const slices = items.map((p) => {
|
||||
const share = Number(p.kwh) / total;
|
||||
const share = Number(p.cost) / total;
|
||||
const sweep = share * Math.PI * 2;
|
||||
const x1 = cx + r * Math.cos(angle);
|
||||
const y1 = cy + r * Math.sin(angle);
|
||||
@@ -57,38 +59,48 @@ function energyPieSvg(parts) {
|
||||
const x2 = cx + r * Math.cos(angle);
|
||||
const y2 = cy + r * Math.sin(angle);
|
||||
const large = sweep > Math.PI ? 1 : 0;
|
||||
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
|
||||
const color = COST_PART_COLORS[p.key] || "#64748b";
|
||||
if (share >= 0.999) {
|
||||
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}"/>`;
|
||||
}
|
||||
return `<path d="M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z" fill="${color}"/>`;
|
||||
}).join("");
|
||||
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Stromanteile">${slices}</svg>`;
|
||||
return `<svg class="energy-pie" viewBox="0 0 84 84" role="img" aria-label="Kostenanteile">${slices}</svg>`;
|
||||
}
|
||||
|
||||
function energyBreakdownHtml(energy) {
|
||||
const parts = energy.parts || [];
|
||||
function costBreakdownHtml(energy, filamentCost) {
|
||||
const fil = Number(filamentCost) || 0;
|
||||
const energyParts = (energy && energy.parts) || [];
|
||||
const parts = [];
|
||||
if (fil > 1e-6) {
|
||||
parts.push({ key: "filament", label: "Filament", kwh: null, cost: fil });
|
||||
}
|
||||
energyParts.forEach((p) => parts.push(p));
|
||||
if (!parts.length) return "";
|
||||
const src = energy.source === "ed" ? "aus ED" : "pauschal";
|
||||
const rate = energy.euro_per_kwh != null
|
||||
const energyEuro = Number((energy && energy.cost) || 0);
|
||||
const totalEuro = fil + energyEuro;
|
||||
const src = energy && energy.source === "ed" ? "Strom aus ED" : (energyParts.length ? "Strom pauschal" : "");
|
||||
const rate = energy && energy.euro_per_kwh != null
|
||||
? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh"
|
||||
: "";
|
||||
const rows = parts.map((p) => {
|
||||
const color = ENERGY_PART_COLORS[p.key] || "#64748b";
|
||||
const color = COST_PART_COLORS[p.key] || "#64748b";
|
||||
return `<tr>
|
||||
<td><span class="energy-swatch" style="background:${color}"></span>${escapeHtml(p.label)}</td>
|
||||
<td class="num">${kwhLabel(p.kwh)}</td>
|
||||
<td class="num">${euro(p.cost)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
const sumKwh = energy && energy.kwh != null ? kwhLabel(energy.kwh) : "—";
|
||||
const hint = [src, rate].filter(Boolean).join(" · ");
|
||||
return `<div class="energy-break">
|
||||
<div class="energy-break-chart">${energyPieSvg(parts)}</div>
|
||||
<div class="energy-break-chart">${costPieSvg(parts)}</div>
|
||||
<div class="energy-break-table">
|
||||
<div class="lib-head"><h3>Strom</h3><span class="tiny">${escapeHtml(src)}${rate ? " · " + rate : ""}</span></div>
|
||||
<div class="lib-head"><h3>Kosten</h3><span class="tiny">${escapeHtml(hint)}</span></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Anteil</th><th class="num">kWh</th><th class="num">€</th></tr></thead>
|
||||
<tbody>${rows}
|
||||
<tr class="energy-total"><td>Summe</td><td class="num">${kwhLabel(energy.kwh)}</td><td class="num">${euro(energy.cost)}</td></tr>
|
||||
<tr class="energy-total"><td>Summe</td><td class="num">${sumKwh}</td><td class="num">${euro(totalEuro)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
@@ -151,7 +163,7 @@ function render(data) {
|
||||
${run.message ? "<p class=\"msg\">" + escapeHtml(run.message) + "</p>" : ""}
|
||||
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
|
||||
</div>
|
||||
${energyBreakdownHtml(energy)}
|
||||
${costBreakdownHtml(energy, run.filament_cost)}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Spulen</h2>
|
||||
|
||||
+221
-68
@@ -78,11 +78,10 @@
|
||||
</div>
|
||||
|
||||
<div class="flow-step ok" data-pane="send" id="step-mode">
|
||||
<h3><span class="step-num">3</span>Wann</h3>
|
||||
<h3><span class="step-num">3</span>Start</h3>
|
||||
<div class="segmented" id="mode" role="radiogroup">
|
||||
<button type="button" data-mode="now" class="on">Jetzt<small>sofort starten</small></button>
|
||||
<button type="button" data-mode="free">Wenn frei<small>auf diesem Drucker</small></button>
|
||||
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
|
||||
<button type="button" data-mode="this" class="on">Dieser Drucker<small>frei → start, sonst Warteschlange</small></button>
|
||||
<button type="button" data-mode="next">Nächster passender<small>Grotti wählt nach Sorte</small></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +188,7 @@ let ablaufDefaults = {
|
||||
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
|
||||
};
|
||||
let ablaufSteps = [];
|
||||
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" };
|
||||
const sel = { fileId: null, hosts: new Set(), mode: "this", tab: "send" };
|
||||
let aceHostsKey = "";
|
||||
|
||||
/* ---------- 3D-Pfad ---------- */
|
||||
@@ -202,6 +201,25 @@ function ensureViewer() {
|
||||
return pathViewer;
|
||||
}
|
||||
|
||||
let toolpathGen = 0;
|
||||
|
||||
function scheduleToolpath(file, progress, deferMs) {
|
||||
const gen = ++toolpathGen;
|
||||
const run = () => {
|
||||
if (gen !== toolpathGen) return;
|
||||
showToolpath(file, progress);
|
||||
};
|
||||
if (deferMs > 0) {
|
||||
if (window.requestIdleCallback) {
|
||||
requestIdleCallback(run, { timeout: Math.max(800, deferMs) });
|
||||
} else {
|
||||
setTimeout(run, deferMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
async function showToolpath(file, progress) {
|
||||
const card = document.getElementById("viewer-card");
|
||||
const caption = document.getElementById("viewer-caption");
|
||||
@@ -221,6 +239,7 @@ async function showToolpath(file, progress) {
|
||||
meta.textContent = "Pfad wird gelesen …";
|
||||
try {
|
||||
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||
if (String(sel.fileId) !== String(file.id)) return;
|
||||
viewer.setPath(data);
|
||||
viewerFileId = file.id;
|
||||
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
||||
@@ -262,7 +281,9 @@ function saveSendMemory() {
|
||||
function loadSendMemory() {
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
|
||||
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode;
|
||||
let mode = data.mode;
|
||||
if (mode === "now" || mode === "free") mode = "this";
|
||||
if (["this", "next"].includes(mode)) sel.mode = mode;
|
||||
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
|
||||
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
|
||||
} catch (error) { /* ignore */ }
|
||||
@@ -309,10 +330,22 @@ function slotTitle(host, slot) {
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function paintIndexOf(filament, fallback) {
|
||||
const raw = filament && filament.paint_index;
|
||||
if (raw === 0 || raw === "0") return 0;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function filamentLabel(filament, index) {
|
||||
const kind = filament.type || "Filament";
|
||||
const paint = paintIndexOf(filament, index);
|
||||
const kind = filament.profile || filament.type || "Filament";
|
||||
const color = filament.color ? " · " + filament.color : "";
|
||||
return "Farbe " + (index + 1) + " · " + kind + color;
|
||||
return "T" + paint + " · " + kind + color;
|
||||
}
|
||||
|
||||
function canStartNow(p) {
|
||||
return isFree(p) && !p.needs_clear;
|
||||
}
|
||||
|
||||
function spoolsOnHost(host) {
|
||||
@@ -326,6 +359,19 @@ function spoolWarnText() {
|
||||
const maps = collectAceMaps();
|
||||
const hosts = selectedHosts();
|
||||
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
|
||||
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
|
||||
function profileOk(spoolRows) {
|
||||
if (!fileProfiles.length) return true;
|
||||
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
||||
if (!names.length) return true;
|
||||
return fileProfiles.every((want) => names.some((n) => {
|
||||
if (n === want) return true;
|
||||
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
|
||||
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
|
||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
||||
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
|
||||
}));
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const name = (printers.find((p) => p.host === host) || {}).name || host;
|
||||
const mapping = maps[host] || [];
|
||||
@@ -334,18 +380,23 @@ function spoolWarnText() {
|
||||
let left = 0;
|
||||
let any = false;
|
||||
const types = [];
|
||||
const mappedSpools = [];
|
||||
mapping.forEach((item) => {
|
||||
const slot = Number(item.ams_index) + 1;
|
||||
const spool = onHost.find((s) => Number(s.ace_slot) === slot);
|
||||
if (spool) {
|
||||
any = true;
|
||||
left += Number(spool.remaining_g) || 0;
|
||||
mappedSpools.push(spool);
|
||||
if (spool.material_type) types.push(spool.material_type.toLowerCase());
|
||||
}
|
||||
});
|
||||
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
|
||||
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||||
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
if (!profileOk(mappedSpools)) {
|
||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||
}
|
||||
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
|
||||
}
|
||||
continue;
|
||||
@@ -359,8 +410,11 @@ function spoolWarnText() {
|
||||
if (left + 0.5 < need) {
|
||||
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||||
}
|
||||
if (!profileOk(external)) {
|
||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||
}
|
||||
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
|
||||
}
|
||||
}
|
||||
@@ -432,16 +486,17 @@ function renderAceMaps(force) {
|
||||
title.className = "ace-printer-name";
|
||||
title.textContent = printer.name;
|
||||
block.appendChild(title);
|
||||
paints.forEach((filament, paintIndex) => {
|
||||
paints.forEach((filament, uiIndex) => {
|
||||
const paint = paintIndexOf(filament, uiIndex);
|
||||
const row = document.createElement("label");
|
||||
row.className = "ace-row";
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "ace-swatch";
|
||||
if (filament.color) swatch.style.background = filament.color;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = filamentLabel(filament, paintIndex);
|
||||
text.textContent = filamentLabel(filament, uiIndex);
|
||||
const select = document.createElement("select");
|
||||
select.dataset.paint = String(paintIndex);
|
||||
select.dataset.paint = String(paint);
|
||||
select.innerHTML = '<option value="">Platz wählen</option>';
|
||||
printer.slots.forEach((slot) => {
|
||||
const option = document.createElement("option");
|
||||
@@ -452,16 +507,30 @@ function renderAceMaps(force) {
|
||||
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
|
||||
if (hex) option.style.borderLeft = "4px solid " + hex;
|
||||
const spoolType = (spool && spool.material_type) || slot.type || "";
|
||||
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
|
||||
if (!select.dataset.picked && filament.type && spoolType
|
||||
const spoolName = ((spool && spool.name) || "").toLowerCase();
|
||||
const wantProfile = (filament.profile || "").toLowerCase();
|
||||
// Vorauswahl: Katalog-Sorte exakt (Meta ≠ Matte), sonst grober Typ
|
||||
let profileHit = false;
|
||||
if (wantProfile && spoolName) {
|
||||
if (spoolName === wantProfile) profileHit = true;
|
||||
else if (spoolName.startsWith(wantProfile) && !/[a-z0-9]/i.test(spoolName.charAt(wantProfile.length) || "")) {
|
||||
const rest = spoolName.slice(wantProfile.length).replace(/^[\s·\-_|/]+/, "");
|
||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (!["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first)) profileHit = true;
|
||||
}
|
||||
}
|
||||
if (!select.dataset.picked && profileHit) {
|
||||
option.selected = true;
|
||||
select.dataset.picked = "1";
|
||||
} else if (!select.dataset.picked && !wantProfile && filament.type && spoolType
|
||||
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
|
||||
option.selected = true;
|
||||
select.dataset.picked = "1";
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (!select.dataset.picked && printer.slots[paintIndex]) {
|
||||
select.value = String(printer.slots[paintIndex].index);
|
||||
if (!select.dataset.picked && printer.slots[uiIndex]) {
|
||||
select.value = String(printer.slots[uiIndex].index);
|
||||
}
|
||||
select.addEventListener("change", updateSpoolWarn);
|
||||
row.appendChild(swatch);
|
||||
@@ -638,8 +707,9 @@ function fillFolderFilter() {
|
||||
}
|
||||
|
||||
function moveSelectHtml(file) {
|
||||
if (!hasWatchDir || file.source !== "watch") return "";
|
||||
const current = folderKey(file);
|
||||
if (!hasWatchDir) return "";
|
||||
if (file.source !== "watch" && file.source !== "upload") return "";
|
||||
const current = file.source === "upload" ? "" : folderKey(file);
|
||||
const opts = [
|
||||
`<option value="" selected disabled>Verschieben…</option>`,
|
||||
`<option value="__root__">Wurzel</option>`,
|
||||
@@ -701,8 +771,8 @@ function sliceOf(file) {
|
||||
}
|
||||
|
||||
function materialText(file) {
|
||||
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))];
|
||||
return types.join("+");
|
||||
const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
|
||||
return labels.join("+");
|
||||
}
|
||||
|
||||
function layerHeightText(file) {
|
||||
@@ -757,8 +827,7 @@ function thumbHtml(file) {
|
||||
|
||||
function libItemHtml(file) {
|
||||
const project = projects.find((p) => String(p.id) === String(file.project_id));
|
||||
const del = file.source === "upload"
|
||||
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
|
||||
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
|
||||
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
|
||||
${thumbHtml(file)}
|
||||
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
|
||||
@@ -882,17 +951,25 @@ function updateSendButton() {
|
||||
let note = "";
|
||||
if (!file) problem = "Erst eine Datei wählen.";
|
||||
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
|
||||
if (sel.mode === "now") {
|
||||
if (sel.mode === "this") {
|
||||
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
|
||||
const busy = chosen.filter((p) => !isFree(p));
|
||||
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein.";
|
||||
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten";
|
||||
} else if (sel.mode === "free") {
|
||||
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection();
|
||||
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt.";
|
||||
const ready = chosen.filter(canStartNow);
|
||||
const later = chosen.filter((p) => !canStartNow(p));
|
||||
if (ready.length && !later.length) {
|
||||
text = "Jetzt starten auf " + namesText(ready);
|
||||
note = "";
|
||||
} else if (!ready.length && later.length) {
|
||||
text = "Einreihen für " + namesText(later);
|
||||
note = "Startet, sobald der Drucker bereit und die Platte frei ist.";
|
||||
} else if (ready.length && later.length) {
|
||||
text = "Starten / einreihen auf " + namesText(chosen);
|
||||
note = namesText(ready) + " sofort, " + namesText(later) + " in die Warteschlange.";
|
||||
} else {
|
||||
text = "Starten";
|
||||
}
|
||||
} else {
|
||||
text = "Einreihen: nächster freier " + familyOfSelection();
|
||||
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine.";
|
||||
text = "Einreihen: nächster passender " + familyOfSelection();
|
||||
note = "Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. PLA Meta ≠ Matte).";
|
||||
}
|
||||
const mismatch = chosen.filter((p) => familyMismatch(p, file));
|
||||
if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
|
||||
@@ -902,7 +979,7 @@ function updateSendButton() {
|
||||
button.disabled = !!problem || !!sendPoll;
|
||||
hint.textContent = problem || note;
|
||||
hint.classList.toggle("bad", !!problem);
|
||||
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
||||
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
||||
}
|
||||
|
||||
function updateAblaufButton() {
|
||||
@@ -971,7 +1048,7 @@ function selectFile(id) {
|
||||
}
|
||||
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
|
||||
refreshFlow();
|
||||
showToolpath(file, 0);
|
||||
scheduleToolpath(file, 0, 0);
|
||||
if (window.matchMedia("(max-width: 1080px)").matches) {
|
||||
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
@@ -1015,19 +1092,72 @@ async function load() {
|
||||
renderLibrary();
|
||||
renderTabs();
|
||||
refreshFlow();
|
||||
const file = selectedFile();
|
||||
if (file) showToolpath(file, 0);
|
||||
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE
|
||||
if (new URLSearchParams(location.search).get("file") && file) {
|
||||
let file = selectedFile();
|
||||
// Orca/Anycubic: Metadaten oft ohne paint_index → ACE zeigt T0/T1 statt T1/T10
|
||||
if (file && file.id && filamentsNeedPaintIndex(file.filaments)) {
|
||||
try {
|
||||
const fresh = await api("/dateien/" + file.id + "/meta", { method: "POST" });
|
||||
if (fresh && fresh.id) {
|
||||
const idx = files.findIndex((row) => String(row.id) === String(fresh.id));
|
||||
if (idx >= 0) files[idx] = fresh;
|
||||
else files.unshift(fresh);
|
||||
file = selectedFile();
|
||||
renderLibrary();
|
||||
refreshFlow();
|
||||
}
|
||||
} catch (error) { /* manuell: Ordner neu lesen */ }
|
||||
}
|
||||
const q = new URLSearchParams(location.search);
|
||||
const deepLink = !!(q.get("file") || q.get("orca"));
|
||||
// Deep-Link / Orca-App: ACE/Senden zuerst — 3D-Pfad friert sonst den Tab ein
|
||||
if (file) scheduleToolpath(file, 0, deepLink ? 900 : 0);
|
||||
if (deepLink) {
|
||||
sel.tab = "send";
|
||||
renderTabs();
|
||||
const opts = document.getElementById("options");
|
||||
if (opts) opts.open = true;
|
||||
const panel = document.querySelector(".send-panel");
|
||||
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
if (file) lastOrcaFocusId = String(file.id);
|
||||
if (q.get("orca")) pullOrcaFocus();
|
||||
}
|
||||
}
|
||||
|
||||
let lastOrcaFocusId = null;
|
||||
|
||||
async function pullOrcaFocus() {
|
||||
try {
|
||||
const data = await api("/orca-fokus");
|
||||
if (!data || data.file_id == null) return;
|
||||
const id = String(data.file_id);
|
||||
const age = Date.now() / 1000 - Number(data.ts || 0);
|
||||
if (age > 180) return;
|
||||
const already = id === String(sel.fileId) && selectedFile();
|
||||
if (id === lastOrcaFocusId && already) return;
|
||||
const switched = lastOrcaFocusId != null && id !== lastOrcaFocusId;
|
||||
lastOrcaFocusId = id;
|
||||
if (already) return;
|
||||
let file = files.find((row) => String(row.id) === id);
|
||||
if (!file) {
|
||||
files = await api("/dateien");
|
||||
renderLibrary();
|
||||
file = files.find((row) => String(row.id) === id);
|
||||
}
|
||||
if (file) {
|
||||
selectFile(file.id);
|
||||
sel.tab = "send";
|
||||
renderTabs();
|
||||
if (switched) toast("Neue Slice-Datei ausgewählt.", "good");
|
||||
}
|
||||
} catch (error) { /* nächster Tick */ }
|
||||
}
|
||||
|
||||
function filamentsNeedPaintIndex(filaments) {
|
||||
const rows = filaments || [];
|
||||
if (rows.length < 2) return false;
|
||||
return rows.some((item) => !item || item.paint_index == null);
|
||||
}
|
||||
|
||||
async function refreshPrinters() {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
@@ -1105,32 +1235,36 @@ async function watchJobs(ids) {
|
||||
await tick();
|
||||
}
|
||||
|
||||
async function postSenden(hosts, whenFree) {
|
||||
return api("/senden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
file_id: Number(sel.fileId),
|
||||
hosts,
|
||||
confirm: true,
|
||||
override_machine: document.getElementById("send-override").checked,
|
||||
bed_leveling: document.getElementById("send-bed-level").checked,
|
||||
ace_maps: collectAceMaps(),
|
||||
when_free: whenFree,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
const button = document.getElementById("send-go");
|
||||
const whenFree = sel.mode !== "now";
|
||||
button.disabled = true;
|
||||
document.getElementById("send-note").textContent = "";
|
||||
try {
|
||||
const payload = await api("/senden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
file_id: Number(sel.fileId),
|
||||
hosts: selectedHosts(),
|
||||
confirm: true,
|
||||
override_machine: document.getElementById("send-override").checked,
|
||||
bed_leveling: document.getElementById("send-bed-level").checked,
|
||||
ace_maps: collectAceMaps(),
|
||||
when_free: whenFree,
|
||||
},
|
||||
});
|
||||
const projectId = document.getElementById("send-project")?.value;
|
||||
if (projectId && sel.fileId) {
|
||||
try {
|
||||
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
|
||||
} catch (error) { /* Projektbindung optional */ }
|
||||
}
|
||||
const jobs = payload.jobs || [];
|
||||
if (whenFree || payload.mode === "when_free") {
|
||||
|
||||
if (sel.mode === "next") {
|
||||
const payload = await postSenden([], true);
|
||||
const jobs = payload.jobs || [];
|
||||
document.getElementById("send-progress").hidden = true;
|
||||
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
|
||||
updateSendButton();
|
||||
@@ -1138,9 +1272,35 @@ async function doSend() {
|
||||
refreshPrinters();
|
||||
return;
|
||||
}
|
||||
showJobsProgress(jobs);
|
||||
|
||||
const chosen = selectedPrinters();
|
||||
const ready = chosen.filter(canStartNow).map((p) => p.host);
|
||||
const later = chosen.filter((p) => !canStartNow(p)).map((p) => p.host);
|
||||
let queued = 0;
|
||||
let nowJobs = [];
|
||||
if (later.length) {
|
||||
const payload = await postSenden(later, true);
|
||||
queued += (payload.jobs || []).length;
|
||||
}
|
||||
if (ready.length) {
|
||||
const payload = await postSenden(ready, false);
|
||||
nowJobs = payload.jobs || [];
|
||||
}
|
||||
if (queued && !nowJobs.length) {
|
||||
document.getElementById("send-progress").hidden = true;
|
||||
toast((queued > 1 ? queued + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
|
||||
updateSendButton();
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
refreshPrinters();
|
||||
return;
|
||||
}
|
||||
if (queued && nowJobs.length) {
|
||||
toast(queued + " eingereiht, " + nowJobs.length + " startet jetzt.", "good");
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
}
|
||||
showJobsProgress(nowJobs);
|
||||
if (typeof refreshSendQueue === "function") refreshSendQueue();
|
||||
await watchJobs(jobs.map((job) => job.id));
|
||||
await watchJobs(nowJobs.map((job) => job.id));
|
||||
} catch (error) {
|
||||
updateSendButton();
|
||||
toast(error.message || String(error), "bad");
|
||||
@@ -1445,7 +1605,7 @@ document.getElementById("targets").addEventListener("click", (event) => {
|
||||
toast("Abläufe gehen nur auf Kobra 3.", "bad");
|
||||
return;
|
||||
}
|
||||
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free";
|
||||
if (sel.tab === "send" && sel.mode === "next") sel.mode = "this";
|
||||
if (sel.hosts.has(host)) sel.hosts.delete(host);
|
||||
else sel.hosts.add(host);
|
||||
saveSendMemory();
|
||||
@@ -1479,19 +1639,11 @@ document.getElementById("send-bed-level").addEventListener("change", updateOptio
|
||||
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
|
||||
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
|
||||
|
||||
document.getElementById("send-go").addEventListener("click", (event) => {
|
||||
const button = event.currentTarget;
|
||||
const warn = spoolWarnText();
|
||||
if (sel.mode === "now") {
|
||||
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
|
||||
} else if (warn) {
|
||||
armButton(button, "Warnung — trotzdem einreihen?", doSend);
|
||||
} else {
|
||||
doSend();
|
||||
}
|
||||
document.getElementById("send-go").addEventListener("click", () => {
|
||||
doSend();
|
||||
});
|
||||
document.getElementById("ablauf-go").addEventListener("click", (event) => {
|
||||
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf);
|
||||
document.getElementById("ablauf-go").addEventListener("click", () => {
|
||||
doAblauf();
|
||||
});
|
||||
|
||||
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
|
||||
@@ -1591,5 +1743,6 @@ loadSendMemory();
|
||||
loadAblaufMemory();
|
||||
load().catch((error) => toast(error.message || String(error), "bad"));
|
||||
setInterval(refreshPrinters, 5000);
|
||||
setInterval(pullOrcaFocus, 2000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -13,15 +13,14 @@
|
||||
<div class="flow-diagram">
|
||||
<span class="step done">Datei</span><span class="step-sep"></span>
|
||||
<span class="step done">Drucker</span><span class="step-sep"></span>
|
||||
<span class="step now">Jetzt · Wenn frei · Nächster freier</span><span class="step-sep"></span>
|
||||
<span class="step now">Dieser Drucker · Nächster passender</span><span class="step-sep"></span>
|
||||
<span class="step">Druck</span><span class="step-sep"></span>
|
||||
<span class="step">Platte räumen</span>
|
||||
</div>
|
||||
<ol>
|
||||
<li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li>
|
||||
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
|
||||
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
|
||||
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
|
||||
<li><strong>Start.</strong> <em>Dieser Drucker</em>: frei → sofort, sonst Warteschlange. <em>Nächster passender</em>: Grotti wählt nach Maschine und Sorte (PLA Meta ≠ Matte).</li>
|
||||
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
|
||||
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
|
||||
</ol>
|
||||
@@ -49,7 +48,8 @@
|
||||
<dl class="terms">
|
||||
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
|
||||
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
|
||||
<dt>Wenn frei</dt><dd>Startet, sobald der Drucker bereit und die Platte geräumt ist.</dd>
|
||||
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
|
||||
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd>
|
||||
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
|
||||
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
|
||||
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=66">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
@@ -81,7 +81,6 @@
|
||||
<span class="nav-label">Live</span>
|
||||
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
||||
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
|
||||
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
|
||||
<span class="nav-label nav-desk">Rückschau</span>
|
||||
@@ -89,6 +88,7 @@
|
||||
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<span class="nav-label nav-desk">Verwalten</span>
|
||||
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</span></a>
|
||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
|
||||
</nav>
|
||||
@@ -138,8 +138,8 @@
|
||||
</nav>
|
||||
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
||||
<nav aria-label="Weitere Seiten">
|
||||
<a href="/kameras">Kameras</a>
|
||||
<a href="/auftraege">Aufträge</a>
|
||||
<a href="/projekte">Projekte</a>
|
||||
<a href="/protokoll">Protokoll</a>
|
||||
<a href="/statistik">Statistik</a>
|
||||
<a href="/aktivitaet">Aktivität</a>
|
||||
|
||||
+118
-5
@@ -40,6 +40,11 @@
|
||||
<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>
|
||||
@@ -141,6 +146,9 @@
|
||||
<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>
|
||||
@@ -237,6 +245,8 @@ let picked = [];
|
||||
let colorLabel = "";
|
||||
let autoName = "";
|
||||
let spoolFilter = "all";
|
||||
let spoolTypeFilter = "";
|
||||
let spoolColorFilter = "";
|
||||
let spoolSearch = "";
|
||||
let sortenSearch = "";
|
||||
let view = "spools";
|
||||
@@ -556,6 +566,9 @@ function fill(spool) {
|
||||
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();
|
||||
@@ -583,12 +596,30 @@ function fillSorten(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,
|
||||
@@ -598,6 +629,28 @@ function shownSpools() {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -635,11 +688,15 @@ function renderSpools() {
|
||||
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
||||
});
|
||||
fillSelects();
|
||||
fillSpoolMetaFilters();
|
||||
|
||||
const body = document.getElementById("spool-rows");
|
||||
body.innerHTML = "";
|
||||
const shown = shownSpools();
|
||||
document.getElementById("spool-count").textContent = String(shown.length);
|
||||
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");
|
||||
if (!spools.length) {
|
||||
note.textContent = "";
|
||||
@@ -651,9 +708,11 @@ function renderSpools() {
|
||||
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;
|
||||
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";
|
||||
@@ -671,7 +730,7 @@ function renderSpools() {
|
||||
body.appendChild(head);
|
||||
}
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
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;
|
||||
@@ -876,6 +935,52 @@ document.getElementById("spool-shelf").addEventListener("click", async () => {
|
||||
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");
|
||||
@@ -890,6 +995,14 @@ document.getElementById("spool-delete").addEventListener("click", () => {
|
||||
closeSpoolPanel();
|
||||
});
|
||||
});
|
||||
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
||||
spoolTypeFilter = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
||||
spoolColorFilter = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
|
||||
document.getElementById("sorten-new").addEventListener("click", () => {
|
||||
fillSorten(null);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
{% extends "layout.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Projekte</h1>
|
||||
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<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">
|
||||
<section class="card">
|
||||
<div class="card-head lib-head">
|
||||
<svg class="ico head-ico"><use href="#i-folder"/></svg>
|
||||
<h2>Liste</h2>
|
||||
<span class="count-pill" id="proj-count">0</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
||||
<tbody id="proj-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="proj-panel">
|
||||
<h2 id="proj-title">Neues Projekt</h2>
|
||||
<form id="proj-form" novalidate>
|
||||
<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">
|
||||
<div class="field">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="proj-folder">Ordner <span class="tip" title="Relativ unter dem Slice-Ordner, z. B. Projekte/2609_002 — kein D:\…"><svg class="ico"><use href="#i-info"/></svg></span></label>
|
||||
<input id="proj-folder" placeholder="Projekte/…" autocomplete="off">
|
||||
<p class="field-help">Leer = automatisch <code>Projekte/<Name></code>. Nur Relativpfad, kein Laufwerk.</p>
|
||||
</div>
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
<div id="proj-assets" class="proj-file-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
|
||||
let projects = [];
|
||||
let selectedId = null;
|
||||
|
||||
function fillBlank() {
|
||||
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-delete").hidden = true;
|
||||
document.getElementById("proj-files-link").hidden = true;
|
||||
document.getElementById("proj-detail").hidden = true;
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
function fill(project) {
|
||||
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 || "";
|
||||
document.getElementById("proj-title").textContent = project.name || "Projekt";
|
||||
document.getElementById("proj-delete").hidden = false;
|
||||
const link = document.getElementById("proj-files-link");
|
||||
link.hidden = false;
|
||||
link.href = "/dateien";
|
||||
document.getElementById("proj-detail").hidden = false;
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
renderList();
|
||||
renderDetail(project);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const body = document.getElementById("proj-rows");
|
||||
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>";
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
projects.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.children[0].textContent = project.name;
|
||||
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
|
||||
tr.children[2].textContent = project.folder || "—";
|
||||
tr.children[2].title = project.folder || "";
|
||||
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
|
||||
tr.addEventListener("click", () => openProject(project.id));
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(project) {
|
||||
const gcodes = document.getElementById("proj-gcodes");
|
||||
const files = project.files || [];
|
||||
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>`;
|
||||
}).join("");
|
||||
}
|
||||
loadAssets(project.id);
|
||||
}
|
||||
|
||||
async function loadAssets(projectId) {
|
||||
const box = document.getElementById("proj-assets");
|
||||
try {
|
||||
const rows = await api("/projekte/" + projectId + "/assets");
|
||||
if (!rows.length) {
|
||||
box.innerHTML = '<p class="tiny">Noch keine weiteren Dateien.</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = rows.map((item) => {
|
||||
const href = withToken("/api/v1/projekte/" + projectId + "/assets/" + encodeURIComponent(item.rel).replace(/%2F/g, "/"));
|
||||
return `<div class="proj-asset">
|
||||
<a href="${escapeHtml(href)}" download>${escapeHtml(item.rel)}</a>
|
||||
<span class="tiny">${escapeHtml(sizeText(item.size_bytes))}</span>
|
||||
<button type="button" class="icon-btn" data-asset-del="${escapeHtml(item.rel)}" title="Löschen">${icon("trash")}</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
} catch (error) {
|
||||
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
|
||||
}
|
||||
}
|
||||
|
||||
function sizeText(bytes) {
|
||||
const n = Number(bytes) || 0;
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
|
||||
}
|
||||
|
||||
async function openProject(id) {
|
||||
try {
|
||||
const row = await api("/projekte/" + id);
|
||||
fill(row);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
projects = await api("/projekte");
|
||||
renderList();
|
||||
if (selectedId) {
|
||||
const still = projects.find((p) => String(p.id) === String(selectedId));
|
||||
if (still) await openProject(selectedId);
|
||||
else fillBlank();
|
||||
}
|
||||
}
|
||||
|
||||
let folderTouched = false;
|
||||
|
||||
document.getElementById("proj-new").addEventListener("click", () => {
|
||||
folderTouched = false;
|
||||
fillBlank();
|
||||
document.getElementById("proj-name").focus();
|
||||
});
|
||||
document.getElementById("proj-folder").addEventListener("input", () => {
|
||||
folderTouched = true;
|
||||
});
|
||||
document.getElementById("proj-name").addEventListener("input", () => {
|
||||
if (folderTouched || document.getElementById("proj-id").value) return;
|
||||
const name = document.getElementById("proj-name").value.trim();
|
||||
const folderEl = document.getElementById("proj-folder");
|
||||
if (!name) {
|
||||
folderEl.value = "";
|
||||
return;
|
||||
}
|
||||
const slug = name
|
||||
.replace(/[^\w\- äöüÄÖÜß.]+/gi, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^[-.]+|[-.]+$/g, "")
|
||||
.slice(0, 60) || "projekt";
|
||||
folderEl.value = "Projekte/" + slug;
|
||||
});
|
||||
|
||||
document.getElementById("proj-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const name = document.getElementById("proj-name").value.trim();
|
||||
if (!name) {
|
||||
toast("Name fehlt.", "bad");
|
||||
document.getElementById("proj-name").focus();
|
||||
return;
|
||||
}
|
||||
const folderRaw = document.getElementById("proj-folder").value.trim();
|
||||
if (/^[a-zA-Z]:[\\/]/.test(folderRaw) || folderRaw.startsWith("\\\\") || folderRaw.includes(":")) {
|
||||
toast("Ordner nur relativ unter dem Slice-Pfad — z. B. Projekte/2609_002, kein D:\\…", "bad");
|
||||
document.getElementById("proj-folder").focus();
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name,
|
||||
status: document.getElementById("proj-status").value,
|
||||
notes: document.getElementById("proj-notes").value,
|
||||
folder: folderRaw,
|
||||
};
|
||||
const saveBtn = document.getElementById("proj-save");
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const saved = id
|
||||
? await api("/projekte/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/projekte", { method: "POST", json: payload });
|
||||
toast(id ? "Projekt gespeichert." : "Projekt angelegt.", "good");
|
||||
folderTouched = true;
|
||||
await load();
|
||||
await openProject(saved.id);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("proj-delete").addEventListener("click", () => {
|
||||
const id = document.getElementById("proj-id").value;
|
||||
if (!id) return;
|
||||
armButton(document.getElementById("proj-delete"), "Ordner inkl. Inhalt?", async () => {
|
||||
try {
|
||||
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
|
||||
toast("Projekt gelöscht.");
|
||||
fillBlank();
|
||||
await load();
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const files = [...(event.target.files || [])];
|
||||
event.target.value = "";
|
||||
if (!id || !files.length) return;
|
||||
try {
|
||||
for (const file of files) {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
await api("/projekte/" + id + "/assets", { method: "POST", body });
|
||||
}
|
||||
toast(files.length === 1 ? "Datei hochgeladen." : files.length + " Dateien hochgeladen.");
|
||||
await loadAssets(id);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
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 rel = btn.dataset.assetDel;
|
||||
if (!id || !rel) return;
|
||||
armButton(btn, "löschen?", async () => {
|
||||
await api("/projekte/" + id + "/assets/" + encodeURIComponent(rel).replace(/%2F/g, "/"), { method: "DELETE" });
|
||||
toast("Datei gelöscht.");
|
||||
await loadAssets(id);
|
||||
});
|
||||
});
|
||||
|
||||
load().catch((error) => showNote("proj-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<p id="run-note"></p>
|
||||
<div class="card table-wrap">
|
||||
<table>
|
||||
<table class="proto-table">
|
||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||
<tbody id="run-rows"></tbody>
|
||||
</table>
|
||||
@@ -65,6 +65,16 @@ function filteredRuns() {
|
||||
});
|
||||
}
|
||||
|
||||
function shortRunName(filename) {
|
||||
let name = (filename || "").split("/").pop() || "";
|
||||
if (!name) return "";
|
||||
name = name.replace(/^\d{8}-\d{6}-/, "");
|
||||
name = name.replace(/^\d{4}-\d{4}-/, "");
|
||||
name = name.replace(/\.gcode$/i, "");
|
||||
name = name.replace(/_\d+(?:\.\d+)?_(?:\d+h)?(?:\d+m)?(?:\d+s)?$/i, "");
|
||||
return name || (filename || "").split("/").pop() || "";
|
||||
}
|
||||
|
||||
function render() {
|
||||
fillPrinterFilter();
|
||||
const body = document.getElementById("run-rows");
|
||||
@@ -83,12 +93,15 @@ function render() {
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = run.printer_name;
|
||||
tr.children[0].className = "proto-printer";
|
||||
const fileCell = tr.children[1];
|
||||
fileCell.className = "proto-file";
|
||||
const fullName = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
const link = document.createElement("a");
|
||||
link.href = "/protokoll/" + run.id;
|
||||
link.className = "cell-file";
|
||||
link.title = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
link.title = fullName;
|
||||
link.textContent = shortRunName(run.filename) || fullName;
|
||||
fileCell.appendChild(link);
|
||||
if (run.chargen_nr) {
|
||||
const code = document.createElement("div");
|
||||
@@ -167,7 +180,10 @@ function render() {
|
||||
} else {
|
||||
const spool = spools.find((item) => item.id === run.spool_id);
|
||||
tr.children[7].textContent = spool ? spool.name : "abgezogen";
|
||||
tr.children[7].title = spool ? spool.name : "";
|
||||
tr.children[7].className = "proto-spool";
|
||||
}
|
||||
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
|
||||
if (run.file_id) {
|
||||
const again = document.createElement("button");
|
||||
again.type = "button";
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar page-tools">
|
||||
<div class="toolbar page-tools stat-tools">
|
||||
<div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-period="all" class="fchip on">Alles</button>
|
||||
<button type="button" data-period="day" class="fchip">Tag</button>
|
||||
@@ -19,20 +19,24 @@
|
||||
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
|
||||
</div>
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="10">
|
||||
<input type="text" id="brand" placeholder="Marke" size="10">
|
||||
<div class="tools-end"><button type="button" id="reload" class="ghost"><svg class="ico"><use href="#i-refresh"/></svg>Aktualisieren</button></div>
|
||||
<div class="stat-filters">
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
|
||||
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
|
||||
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p id="stat-note"></p>
|
||||
<div id="stat" class="statistik"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
function kpi(label, value, hint, ico) {
|
||||
return `<div class="kpi"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
|
||||
function kpi(label, value, hint, ico, extraClass) {
|
||||
const empty = !value || value === "—" || value === "0 %";
|
||||
const cls = ["kpi", extraClass || "", empty ? "kpi-empty" : ""].filter(Boolean).join(" ");
|
||||
return `<div class="${cls}"><div class="kpi-label">${ico ? icon(ico) : ""}${escapeHtml(label)}</div><div class="kpi-value">${value}</div>${hint ? '<div class="kpi-sub">' + escapeHtml(hint) + "</div>" : ""}</div>`;
|
||||
}
|
||||
|
||||
function isoDate(date) {
|
||||
@@ -165,10 +169,11 @@ function render(data) {
|
||||
<tr>
|
||||
<td>${escapeHtml(m.material_type || "—")}</td>
|
||||
<td>${escapeHtml(m.brand || "")}</td>
|
||||
<td>${escapeHtml(m.color || "")}</td>
|
||||
<td class="num">${grams(m.weight_g)}</td>
|
||||
<td class="num">${euro(m.cost)}</td>
|
||||
<td class="num">${m.jobs}</td>
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="6">Kein Spulenabzug im Zeitraum.</td></tr>';
|
||||
|
||||
const chartPrinters = (data.by_printer || []).map((p) => ({
|
||||
...p,
|
||||
@@ -180,16 +185,18 @@ function render(data) {
|
||||
}));
|
||||
|
||||
document.getElementById("stat").innerHTML = `
|
||||
<div class="kpi-row">
|
||||
<div class="kpi-row kpi-primary">
|
||||
${kpi("Läufe", String(jobs), "", "layers")}
|
||||
${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
|
||||
${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
|
||||
${kpi("Kosten", euro(o.cost), "", "euro")}
|
||||
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban")}
|
||||
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent")}
|
||||
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge")}
|
||||
</div>
|
||||
<div class="kpi-row kpi-secondary">
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban", "kpi-sm")}
|
||||
${kpi("Stück-Yield", o.yield_percent != null ? Math.round(o.yield_percent) + " %" : "—", (o.pieces_ok || 0) + " gut · " + (o.pieces_bad || 0) + " schlecht", "percent", "kpi-sm")}
|
||||
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag", "kpi-sm")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge", "kpi-sm")}
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
@@ -198,7 +205,7 @@ function render(data) {
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Verbrauch je Material</h2>
|
||||
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand].filter(Boolean).join(" · ") || "—")}
|
||||
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand, m.color].filter(Boolean).join(" · ") || "—")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -218,7 +225,7 @@ function render(data) {
|
||||
<div class="card">
|
||||
<h2>Nach Material</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
|
||||
<thead><tr><th>Sorte</th><th>Marke</th><th>Farbe</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
|
||||
<tbody>${materials}</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
@@ -231,17 +238,25 @@ async function load() {
|
||||
const host = document.getElementById("host").value;
|
||||
const material = document.getElementById("material").value.trim();
|
||||
const brand = document.getElementById("brand").value.trim();
|
||||
const color = document.getElementById("color").value.trim();
|
||||
if (since) params.set("since", since);
|
||||
if (until) params.set("until", until);
|
||||
if (host) params.set("host", host);
|
||||
if (material) params.set("material", material);
|
||||
if (brand) params.set("brand", brand);
|
||||
if (color) params.set("color", color);
|
||||
const q = params.toString();
|
||||
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
|
||||
const data = await api("/statistik" + (q ? "?" + q : ""));
|
||||
render(data);
|
||||
}
|
||||
|
||||
let filterTimer = null;
|
||||
function scheduleLoad() {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(() => load().catch((error) => showNote("stat-note", error)), 280);
|
||||
}
|
||||
|
||||
function applyPeriod(key) {
|
||||
const range = periodRange(key);
|
||||
document.getElementById("since").value = range.since;
|
||||
@@ -256,28 +271,18 @@ document.getElementById("period").addEventListener("click", (event) => {
|
||||
applyPeriod(button.dataset.period);
|
||||
});
|
||||
|
||||
document.getElementById("reload").addEventListener("click", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
["since", "until"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => {
|
||||
clearPeriodActive();
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
scheduleLoad();
|
||||
});
|
||||
});
|
||||
document.getElementById("host").addEventListener("change", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
["material", "brand"].forEach((id) => {
|
||||
document.getElementById("host").addEventListener("change", scheduleLoad);
|
||||
["material", "brand", "color"].forEach((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.addEventListener("change", () => load().catch((error) => showNote("stat-note", error)));
|
||||
node.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
}
|
||||
});
|
||||
node.addEventListener("input", scheduleLoad);
|
||||
node.addEventListener("change", scheduleLoad);
|
||||
});
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
</script>
|
||||
|
||||
+121
-32
@@ -340,6 +340,14 @@ function flowHtml(printer) {
|
||||
return bits.join("");
|
||||
}
|
||||
|
||||
function filamentPauseHint(printer) {
|
||||
if (!printer) return "";
|
||||
if (printer.filament_empty) return printer.pause_reason || printer.message || "Filament leer";
|
||||
const text = String(printer.pause_reason || printer.message || "");
|
||||
if (/filament|spule|material.?leer|gerissen|runout|10107/i.test(text)) return text;
|
||||
return "";
|
||||
}
|
||||
|
||||
function stripsHtml(printer, state) {
|
||||
const bits = [];
|
||||
const busy = state === "printing" || state === "paused" || state === "heating";
|
||||
@@ -348,6 +356,14 @@ function stripsHtml(printer, state) {
|
||||
if (state === "error") {
|
||||
bits.push(`<div class="strip bad">${icon("alert")}<span class="grow">${escapeHtml(printer.message || "Drucker meldet einen Fehler.")}</span><button type="button" class="warn" data-cmd="clear_error">Fehler löschen</button></div>`);
|
||||
}
|
||||
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
|
||||
if (filamentHint) {
|
||||
bits.push(`<div class="strip warn filament-swap">
|
||||
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
|
||||
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
|
||||
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
|
||||
</div>`);
|
||||
}
|
||||
const failed = !queueBusy && printer.queue_failed;
|
||||
if (failed) {
|
||||
const step = failed.step_label_current || "Schritt " + ((Number(failed.step_index) || 0) + 1);
|
||||
@@ -417,7 +433,11 @@ function aceSlotLabel(slot) {
|
||||
|
||||
function aceSteerHtml(printer) {
|
||||
const dryers = printer.dryers || [];
|
||||
if (!dryers.length) return "";
|
||||
const loaded = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||
if (!dryers.length && !loaded.length) return "";
|
||||
const bits = [];
|
||||
dryers.forEach((dryer) => {
|
||||
const id = Number(dryer.id);
|
||||
@@ -443,10 +463,6 @@ function aceSteerHtml(printer) {
|
||||
</div>
|
||||
</div>`);
|
||||
});
|
||||
const loaded = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||
if (loaded.length) {
|
||||
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
|
||||
loaded.forEach((spool) => {
|
||||
@@ -472,16 +488,22 @@ function steerPanelHtml(printer) {
|
||||
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
||||
const aceKey = aceSteerKey(printer);
|
||||
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
|
||||
<div class="steer-row">
|
||||
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
||||
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
||||
<button type="button" data-steer-go="temps">Temp</button>
|
||||
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
|
||||
<div class="steer-group">
|
||||
<div class="steer-group-label">Heizung</div>
|
||||
<div class="steer-row">
|
||||
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
||||
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
||||
<button type="button" data-steer-go="temps">Temp</button>
|
||||
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="steer-row">
|
||||
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
|
||||
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
||||
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||
<div class="steer-group">
|
||||
<div class="steer-group-label">Druck</div>
|
||||
<div class="steer-row">
|
||||
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
|
||||
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
||||
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||
</div>
|
||||
</div>
|
||||
${aceSteerHtml(printer)}
|
||||
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
||||
@@ -564,19 +586,24 @@ function factsHtml(printer) {
|
||||
`;
|
||||
}
|
||||
|
||||
function factChip(label, value, ico) {
|
||||
if (!value && value !== 0) return "";
|
||||
return `<span class="fact-chip">${ico || ""}<span class="fact-chip-label">${escapeHtml(label)}</span><b>${escapeHtml(String(value))}</b></span>`;
|
||||
}
|
||||
|
||||
function techFactsHtml(printer) {
|
||||
const part = fanPct(printer, "part") || fanPct(printer);
|
||||
const chamberFan = fanPct(printer, "chamber");
|
||||
const auxFan = fanPct(printer, "aux");
|
||||
const speed = speedPct(printer);
|
||||
return `
|
||||
${part ? "<span class=\"fact-ico\">" + icon("fan") + " Teil <b>" + part + "</b></span>" : ""}
|
||||
${chamberFan ? "<span class=\"fact-ico\">" + icon("fan-chamber") + " Bauraum <b>" + chamberFan + "</b></span>" : ""}
|
||||
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
|
||||
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
||||
${anycubicTaskLine(printer)}
|
||||
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
||||
`;
|
||||
return [
|
||||
part ? factChip("Teil", part, icon("fan")) : "",
|
||||
chamberFan ? factChip("Bauraum", chamberFan, icon("fan-chamber")) : "",
|
||||
auxFan ? factChip("Aux", auxFan, icon("fan-aux")) : "",
|
||||
speed ? factChip("Speed", speed) : "",
|
||||
anycubicTaskLine(printer),
|
||||
factChip("Host", printer.host),
|
||||
].filter(Boolean).join("");
|
||||
}
|
||||
|
||||
function anycubicTaskLine(printer) {
|
||||
@@ -584,13 +611,11 @@ function anycubicTaskLine(printer) {
|
||||
if (!task || typeof task !== "object") return "";
|
||||
const state = String(task.state || "").trim();
|
||||
const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : "";
|
||||
const file = task.filename ? String(task.filename) : "";
|
||||
if (!state && !tid && !file) return "";
|
||||
if (!state && !tid) return "";
|
||||
const bits = [];
|
||||
if (state) bits.push(state);
|
||||
if (tid) bits.push("task " + tid);
|
||||
if (file) bits.push(file);
|
||||
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
||||
if (tid) bits.push("#" + tid);
|
||||
return factChip("Anycubic", bits.join(" · "));
|
||||
}
|
||||
|
||||
function jobLineVisible(printer, state, filename) {
|
||||
@@ -626,12 +651,15 @@ function buildCard(printer) {
|
||||
article.dataset.needsClear = needsClear(printer) ? "1" : "";
|
||||
article.innerHTML = `
|
||||
<div class="machine-head">
|
||||
<div class="machine-title">
|
||||
<h2></h2>
|
||||
${badge}
|
||||
<span data-role="chip"></span>
|
||||
<div class="machine-head-main">
|
||||
<div class="machine-title">
|
||||
<h2></h2>
|
||||
${badge}
|
||||
<span data-role="chip"></span>
|
||||
</div>
|
||||
<div data-role="hero"></div>
|
||||
</div>
|
||||
<div data-role="hero"></div>
|
||||
<img class="wall-cam-thumb" data-role="wall-cam" alt="" hidden>
|
||||
</div>
|
||||
<div class="machine-body">
|
||||
<div data-role="bar"></div>
|
||||
@@ -655,6 +683,59 @@ function buildCard(printer) {
|
||||
return article;
|
||||
}
|
||||
|
||||
/** Wand-Thumb immer Snapshot — nie Stream, unabhängig von der Browser-Einstellung. */
|
||||
function wallCamSnapshotUrl(printer) {
|
||||
if (!printer || !printer.camera || printer.has_camera === false) return "";
|
||||
return withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
|
||||
}
|
||||
|
||||
function patchWallCam(article, printer) {
|
||||
const img = article.querySelector('[data-role="wall-cam"]');
|
||||
const head = article.querySelector(".machine-head");
|
||||
if (!img) return;
|
||||
const next = wallCamSnapshotUrl(printer);
|
||||
if (!next) {
|
||||
img.hidden = true;
|
||||
img.removeAttribute("src");
|
||||
img.dataset.srcKey = "";
|
||||
img.dataset.pending = "";
|
||||
if (head) head.classList.remove("has-cam");
|
||||
return;
|
||||
}
|
||||
img.alt = "Kamera " + (printer.name || printer.host || "");
|
||||
if (img.dataset.srcKey === next) {
|
||||
img.hidden = false;
|
||||
if (head) head.classList.add("has-cam");
|
||||
return;
|
||||
}
|
||||
img.dataset.pending = next;
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
if (img.dataset.pending !== next) return;
|
||||
img.dataset.srcKey = next;
|
||||
img.dataset.pending = "";
|
||||
img.src = next;
|
||||
img.hidden = false;
|
||||
if (head) head.classList.add("has-cam");
|
||||
};
|
||||
probe.onerror = () => {
|
||||
if (img.dataset.pending !== next) return;
|
||||
img.dataset.pending = "";
|
||||
img.hidden = true;
|
||||
img.removeAttribute("src");
|
||||
img.dataset.srcKey = "";
|
||||
if (head) head.classList.remove("has-cam");
|
||||
};
|
||||
probe.src = next;
|
||||
}
|
||||
|
||||
function refreshWallCams() {
|
||||
for (const [host, article] of cardNodes.entries()) {
|
||||
const printer = printerCache.get(host);
|
||||
if (printer) patchWallCam(article, printer);
|
||||
}
|
||||
}
|
||||
|
||||
function steerHasFocus(root) {
|
||||
const active = document.activeElement;
|
||||
return !!(active && root.contains(active) && active.matches("input, select, textarea, button"));
|
||||
@@ -772,6 +853,7 @@ function patchCard(article, printer) {
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
patchWallCam(article, printer);
|
||||
}
|
||||
|
||||
function refreshDeskCamera(printer) {
|
||||
@@ -1618,6 +1700,12 @@ async function handleWallClick(event) {
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (button.dataset.cmd === "fortsetzen") {
|
||||
const card = printerCache.get(host);
|
||||
if (card && filamentPauseHint(card)) {
|
||||
toast("Neue Spule unter Material dem Drucker zuweisen — sonst bleibt der Abzug bei der alten Rolle.");
|
||||
}
|
||||
}
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/" + button.dataset.cmd, { method: "POST", json: {} });
|
||||
showNote("note", null);
|
||||
refresh();
|
||||
@@ -1665,6 +1753,7 @@ deskEl.addEventListener("keydown", async (event) => {
|
||||
|
||||
refresh().catch((error) => showNote("note", error));
|
||||
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
|
||||
setInterval(refreshWallCams, 3000);
|
||||
setInterval(() => {
|
||||
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
||||
|
||||
Reference in new issue
Block a user