686 lines
29 KiB
HTML
686 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="csrf" content="{{ csrf }}">
|
|
{% if current_user %}<meta name="user-role" content="{{ current_user.role }}">{% endif %}
|
|
<title>{{ title }} · Grotti</title>
|
|
<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=73">
|
|
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
|
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
|
<script>
|
|
(function () {
|
|
function applyThemeEarly(mode) {
|
|
var light = mode === "light";
|
|
if (mode === "system") {
|
|
try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) { light = false; }
|
|
}
|
|
document.documentElement.dataset.theme = light ? "light" : "dark";
|
|
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
|
var meta = document.getElementById("theme-color-meta");
|
|
if (meta) meta.content = light ? "#f4f4f6" : "#0a0a0c";
|
|
}
|
|
window.applyTheme = function (mode) {
|
|
try { if (mode) localStorage.setItem("grotti-theme", mode); } catch (e) {}
|
|
var m = mode;
|
|
try { m = m || localStorage.getItem("grotti-theme") || "dark"; } catch (e) { m = "dark"; }
|
|
applyThemeEarly(m);
|
|
};
|
|
try {
|
|
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
|
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
|
|
}
|
|
} catch (e) {}
|
|
try {
|
|
var q = new URLSearchParams(location.search);
|
|
if (q.has("shelf")) {
|
|
var on = q.get("shelf") === "1" || q.get("shelf") === "true";
|
|
localStorage.setItem("grotti-display", on ? "shelf" : "desktop");
|
|
q.delete("shelf");
|
|
var next = location.pathname + (q.toString() ? "?" + q : "") + location.hash;
|
|
history.replaceState(null, "", next);
|
|
}
|
|
} catch (e) {}
|
|
try {
|
|
if (localStorage.getItem("grotti-display") === "shelf") document.documentElement.classList.add("shelf");
|
|
var nav = localStorage.getItem("grotti-nav-collapsed");
|
|
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
|
document.documentElement.classList.add("nav-collapsed");
|
|
}
|
|
applyThemeEarly(localStorage.getItem("grotti-theme") || "dark");
|
|
} catch (e) {}
|
|
try {
|
|
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () {
|
|
var mode = "dark";
|
|
try { mode = localStorage.getItem("grotti-theme") || "dark"; } catch (err) {}
|
|
if (mode === "system") applyThemeEarly("system");
|
|
});
|
|
} catch (e) {}
|
|
})();
|
|
</script>
|
|
</head>
|
|
<body class="app-shell">
|
|
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
|
|
{{ icons_sprite }}
|
|
<aside class="sidebar">
|
|
<div class="brand-row">
|
|
<a class="brand" href="/" title="Grotti">
|
|
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
|
|
<span class="brand-name">Grotti</span>
|
|
</a>
|
|
<div class="brand-tools">
|
|
<button type="button" class="icon-btn m-only" id="cmdk-open-m" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg></button>
|
|
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
|
</div>
|
|
</div>
|
|
<nav class="nav-main" aria-label="Hauptmenü">
|
|
<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="/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-clipboard"/></svg><span>Aufträge</span></a>
|
|
<span class="nav-label nav-desk">Rückschau</span>
|
|
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
|
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
|
|
<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>
|
|
</aside>
|
|
<div class="app-main">
|
|
<header class="topbar">
|
|
<button type="button" class="cmdk-btn" id="cmdk-open" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg><span>Suche</span><kbd>Ctrl K</kbd></button>
|
|
<div class="live-bar" id="live-bar">
|
|
<div class="live-chips" id="live-chips"></div>
|
|
{% if page != 'jetzt' %}
|
|
<div class="live-pop" id="live-pop" hidden>
|
|
<div class="live-panel" id="live-panel">
|
|
{% include "_live_panel.html" %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
<div class="topbar-tools">
|
|
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
|
|
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
|
|
{% if current_user %}
|
|
<details class="user-menu" id="user-menu">
|
|
<summary class="side-avatar" aria-label="Benutzer-Menü">{{ current_user.username[:1]|upper }}</summary>
|
|
<div class="menu-pop" role="menu">
|
|
<div class="menu-user">
|
|
<strong>{{ current_user.username }}</strong>
|
|
<span class="tiny">{{ current_user.role }}</span>
|
|
</div>
|
|
<button type="button" class="ghost" id="logout-btn" role="menuitem">Abmelden</button>
|
|
<a href="/doku" role="menuitem">v{{ version }}</a>
|
|
<span class="tiny menu-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
</div>
|
|
</header>
|
|
<main>
|
|
{% block body %}{% endblock %}
|
|
</main>
|
|
</div>
|
|
<nav class="mobile-nav" aria-label="Schnellmenü">
|
|
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
|
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
|
|
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
|
<a href="/jetzt" class="{{ 'on' if page == 'jetzt' else '' }}"><svg class="ico"><use href="#i-sparkles"/></svg><span>Jetzt</span><span class="nav-badge" id="mobile-jetzt-badge" hidden>0</span></a>
|
|
<button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
|
|
</nav>
|
|
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
|
<nav aria-label="Weitere Seiten">
|
|
<p class="nav-label">Live</p>
|
|
<a href="/auftraege">Aufträge</a>
|
|
<p class="nav-label">Rückschau</p>
|
|
<a href="/protokoll">Protokoll</a>
|
|
<a href="/statistik">Statistik</a>
|
|
<a href="/aktivitaet">Aktivität</a>
|
|
<p class="nav-label">Verwalten</p>
|
|
<a href="/projekte">Projekte</a>
|
|
<a href="/material">Material</a>
|
|
<a href="/wartung">Wartung</a>
|
|
<a href="/einstellungen">Einstellungen</a>
|
|
<a href="/doku">Doku</a>
|
|
</nav>
|
|
{% if current_user %}
|
|
<div class="mobile-more-user">
|
|
<span class="side-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
|
|
<span class="grow">
|
|
<strong>{{ current_user.username }}</strong>
|
|
<span class="tiny">{{ current_user.role }} · v{{ version }}</span>
|
|
</span>
|
|
<button type="button" class="ghost tiny-btn" id="logout-btn-m">Abmelden</button>
|
|
</div>
|
|
{% endif %}
|
|
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
|
</dialog>
|
|
<script src="{{ url_for('static', filename='api.js') }}?v=33"></script>
|
|
<script src="{{ url_for('static', filename='ui.js') }}?v=6"></script>
|
|
<script>
|
|
async function doLogout() {
|
|
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
|
location.href = "/login";
|
|
}
|
|
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
|
|
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
|
|
(function userMenuClose() {
|
|
const menu = document.getElementById("user-menu");
|
|
if (!menu) return;
|
|
document.addEventListener("click", (event) => {
|
|
if (!menu.open) return;
|
|
if (menu.contains(event.target)) return;
|
|
menu.open = false;
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape" && menu.open) menu.open = false;
|
|
});
|
|
})();
|
|
(function applyShellClasses() {
|
|
const shelf = document.documentElement.classList.contains("shelf");
|
|
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
|
document.body.classList.toggle("shelf", shelf);
|
|
document.body.classList.toggle("nav-collapsed", collapsed);
|
|
const toggle = document.getElementById("nav-toggle");
|
|
if (toggle) {
|
|
toggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
|
|
toggle.title = collapsed ? "Menü ausklappen" : "Menü einklappen";
|
|
}
|
|
})();
|
|
|
|
const LIVE_KEY = "grotti-live-closed";
|
|
const NAV_KEY = "grotti-nav-collapsed";
|
|
const DISPLAY_KEY = "grotti-display";
|
|
|
|
function setNavCollapsed(on) {
|
|
document.documentElement.classList.toggle("nav-collapsed", on);
|
|
document.body.classList.toggle("nav-collapsed", on);
|
|
try { localStorage.setItem(NAV_KEY, on ? "1" : "0"); } catch (error) {}
|
|
const toggle = document.getElementById("nav-toggle");
|
|
if (toggle) {
|
|
toggle.setAttribute("aria-pressed", on ? "true" : "false");
|
|
toggle.title = on ? "Menü ausklappen" : "Menü einklappen";
|
|
}
|
|
}
|
|
|
|
function setDisplayMode(mode) {
|
|
const shelf = mode === "shelf";
|
|
document.documentElement.classList.toggle("shelf", shelf);
|
|
document.body.classList.toggle("shelf", shelf);
|
|
try { localStorage.setItem(DISPLAY_KEY, shelf ? "shelf" : "desktop"); } catch (error) {}
|
|
if (shelf && localStorage.getItem(NAV_KEY) == null) setNavCollapsed(true);
|
|
}
|
|
|
|
document.getElementById("nav-toggle")?.addEventListener("click", () => {
|
|
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
|
|
});
|
|
function openCmdk() {
|
|
if (typeof openCommandPalette === "function") openCommandPalette();
|
|
}
|
|
document.getElementById("cmdk-open")?.addEventListener("click", openCmdk);
|
|
document.getElementById("cmdk-open-m")?.addEventListener("click", openCmdk);
|
|
|
|
(function restoreBoxes() {
|
|
let closed = [];
|
|
try { closed = JSON.parse(localStorage.getItem(LIVE_KEY) || "[]"); } catch (error) { closed = []; }
|
|
if (window.matchMedia("(max-width: 760px)").matches && !localStorage.getItem(LIVE_KEY)) closed = ["wait", "send"];
|
|
document.querySelectorAll("#live-panel details[data-box]").forEach((box) => {
|
|
if (closed.includes(box.dataset.box)) box.open = false;
|
|
box.addEventListener("toggle", () => {
|
|
const now = [...document.querySelectorAll("#live-panel details[data-box]")].filter((d) => !d.open).map((d) => d.dataset.box);
|
|
localStorage.setItem(LIVE_KEY, JSON.stringify(now));
|
|
});
|
|
});
|
|
})();
|
|
|
|
function queueTarget(job) {
|
|
return job.printer_name || job.host || (job.family_label ? "nächster " + job.family_label : "nächster freier");
|
|
}
|
|
|
|
async function cancelQueueItem(job, button) {
|
|
const running = job.status === "running";
|
|
const go = async () => {
|
|
if (button) button.disabled = true;
|
|
const prev = { status: job.status, priority: job.priority };
|
|
try {
|
|
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
|
|
if (running) {
|
|
toast("Abgebrochen.");
|
|
} else if (typeof undoToast === "function") {
|
|
undoToast("Aus der Warteschlange genommen.", async () => {
|
|
await api("/druck-queue/" + job.id, {
|
|
method: "PATCH",
|
|
json: { status: "waiting", priority: prev.priority, undo: true },
|
|
});
|
|
toast("Wieder eingereiht.");
|
|
refreshJetzt();
|
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
});
|
|
} else {
|
|
toast("Aus der Warteschlange genommen.");
|
|
}
|
|
} catch (error) {
|
|
toast(error.message, "bad");
|
|
}
|
|
refreshJetzt();
|
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
if (button) button.disabled = false;
|
|
};
|
|
if (running && button) armButton(button, job.is_ablauf ? "Ablauf stoppen?" : "Druck stoppen?", go);
|
|
else go();
|
|
}
|
|
window.cancelQueueItem = cancelQueueItem;
|
|
|
|
function setListHtml(el, html) {
|
|
if (!el) return;
|
|
if (el._html === html) return;
|
|
el._html = html;
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
function shortPrinter(name) {
|
|
let text = String(name || "").replace(/^Kobra\s+(?:3\s+)?/i, "").trim() || String(name || "");
|
|
return text.length > 12 ? text.slice(0, 10) + "…" : text;
|
|
}
|
|
|
|
function openLivePop(section) {
|
|
const pop = document.getElementById("live-pop");
|
|
if (!pop) {
|
|
const box = section ? document.querySelector(`[data-box="${section}"]`) : null;
|
|
if (box) {
|
|
box.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
return;
|
|
}
|
|
location.href = section ? "/jetzt#" + encodeURIComponent(section) : "/jetzt";
|
|
return;
|
|
}
|
|
pop.hidden = false;
|
|
if (section) {
|
|
const box = pop.querySelector(`[data-box="${section}"]`);
|
|
if (box) box.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}
|
|
|
|
function closeLivePop() {
|
|
const pop = document.getElementById("live-pop");
|
|
if (pop) pop.hidden = true;
|
|
}
|
|
|
|
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, host }) {
|
|
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
|
|
const finish = remain > 0 ? finishClock(remain) : "";
|
|
const bits = [name];
|
|
if (tipExtra) bits.push(tipExtra);
|
|
else if (pct > 0 || remain > 0) bits.push(pct + " %");
|
|
if (finish) bits.push(finish);
|
|
const tip = [
|
|
filename ? jobLabel(filename) : "",
|
|
remain > 0 ? "noch " + hours(remain) : "",
|
|
tipExtra ? "Schritt " + tipExtra : "",
|
|
].filter(Boolean).join(" · ");
|
|
const href = host ? "/#" + encodeURIComponent(host) : "/";
|
|
return `<li class="queue-item running run-line" style="--pct:${pct}">
|
|
<a class="queue-link run-line-link" href="${escapeHtml(href)}" title="${escapeHtml(tip || name || "Drucker")}">
|
|
<span class="run-line-fill" aria-hidden="true"></span>
|
|
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
|
|
</a>
|
|
${cancelId != null ? `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${cancelId}" title="Abbrechen">${icon("x")}</button></span>` : ""}
|
|
</li>`;
|
|
}
|
|
|
|
function actionItemHtml(action) {
|
|
const kind = action.kind || "";
|
|
const cls = kind === "queue_error" || kind === "bedarf" || kind === "wartung" || kind === "auftrag" || kind === "filament_pause" ? "error" : "";
|
|
let tools = "";
|
|
if (kind === "clear" && action.host) {
|
|
tools = `<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(action.host)}" title="Platte geräumt" aria-label="Platte geräumt">${icon("check")}</button></span>`;
|
|
} else if (kind === "queue_error" && action.id) {
|
|
if (action.is_ablauf) {
|
|
tools = `<span class="queue-tools"><button type="button" class="icon-btn" data-resume-q="${action.id}" title="Fortsetzen">${icon("play")}</button>
|
|
<button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
|
} else {
|
|
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
|
}
|
|
} else if (kind === "waiting" && action.id) {
|
|
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${action.id}" title="Entfernen">${icon("x")}</button></span>`;
|
|
}
|
|
const href = action.href || "/warteschlange";
|
|
const name = action.printer || action.title || "";
|
|
const detail = action.detail || action.filename || "";
|
|
return `<li class="queue-item ${cls}">
|
|
<div class="queue-row">
|
|
<a class="queue-link grow" href="${escapeHtml(href)}"><span class="queue-name">${escapeHtml(action.title || name)}</span></a>
|
|
${tools}
|
|
</div>
|
|
<span class="queue-msg">${escapeHtml([name !== action.title ? name : "", detail].filter(Boolean).join(" · "))}</span>
|
|
</li>`;
|
|
}
|
|
|
|
function renderLiveChips({ actionItems, running, activeSends, alertCount, etaText }) {
|
|
const box = document.getElementById("live-chips");
|
|
if (!box) return;
|
|
const bits = [];
|
|
if (etaText) {
|
|
bits.push(`<span class="live-eta">${escapeHtml(etaText)}</span>`);
|
|
}
|
|
if (actionItems.length) {
|
|
bits.push(`<button type="button" class="live-chip todo" data-live-open="jetzt">${icon("sparkles")}<span>Jetzt</span><span class="count todo">${actionItems.length}</span></button>`);
|
|
}
|
|
running.forEach((p) => {
|
|
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
|
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
|
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
|
|
const starting = p.starting || p.state === "starting";
|
|
const label = [
|
|
shortPrinter(p.name || p.host),
|
|
pausedFil ? "Material!" : (starting ? "startet" : (pct ? pct + "%" : "")),
|
|
finish,
|
|
].filter(Boolean).join(" · ");
|
|
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
|
|
bits.push(`<button type="button" class="live-chip run${pausedFil ? " warn" : ""}" data-live-open="run" style="--pct:${pct}" title="${escapeHtml(tip || p.name || "")}">
|
|
<span class="live-chip-fill" aria-hidden="true"></span>
|
|
<span class="live-chip-text">${escapeHtml(label)}</span>
|
|
</button>`);
|
|
});
|
|
if (activeSends > 0) {
|
|
bits.push(`<button type="button" class="live-chip" data-live-open="send">${icon("send")}<span>Senden</span><span class="count">${activeSends}</span></button>`);
|
|
}
|
|
if (alertCount > 0) {
|
|
bits.push(`<button type="button" class="live-chip bad" data-live-open="alerts">${icon("bell")}<span>Meldungen</span><span class="count bad">${alertCount}</span></button>`);
|
|
}
|
|
setListHtml(box, bits.join(""));
|
|
box.querySelectorAll("[data-live-open]").forEach((btn) => {
|
|
btn.onclick = () => openLivePop(btn.dataset.liveOpen);
|
|
});
|
|
}
|
|
|
|
function bindLivePanelActions(panel) {
|
|
if (!panel) return;
|
|
panel.querySelectorAll("[data-cleared]").forEach((btn) => {
|
|
btn.onclick = async () => {
|
|
const host = btn.dataset.cleared;
|
|
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
|
|
if (!payload) return;
|
|
btn.disabled = true;
|
|
try {
|
|
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
|
|
toast("Platte frei.");
|
|
} catch (error) { toast(error.message, "bad"); }
|
|
refreshJetzt();
|
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
btn.disabled = false;
|
|
};
|
|
});
|
|
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
|
btn.onclick = () => cancelQueueItem({ id: Number(btn.dataset.cancelQ), status: "waiting" }, btn);
|
|
});
|
|
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
|
|
btn.onclick = () => armButton(btn, "Fortsetzen?", async () => {
|
|
try {
|
|
await api("/druck-queue/" + btn.dataset.resumeQ, { method: "PATCH", json: { status: "running", resume: true } });
|
|
toast("Ablauf geht weiter.");
|
|
} catch (error) { toast(error.message, "bad"); }
|
|
refreshJetzt();
|
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
});
|
|
});
|
|
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
|
|
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
|
|
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
|
|
refreshJetzt();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refreshJetzt() {
|
|
let data;
|
|
try { data = await api("/jetzt"); } catch (error) { return; }
|
|
// Wartende ohne Materialgrund nicht in Jetzt — nur echte Handlungen + Material-Wartegründe
|
|
const actionItems = (data.actions || []).filter((a) => a.kind !== "waiting" || a.reason);
|
|
const running = data.running || [];
|
|
const eta = data.eta || {};
|
|
const multiEta = running.filter((p) => Number(p.remain) > 0).length >= 2;
|
|
const etaText = multiEta && eta.all_done_in_s > 0 ? "Alles fertig um " + finishClock(eta.all_done_in_s) : "";
|
|
|
|
const etaEl = document.getElementById("jetzt-eta");
|
|
if (etaEl) {
|
|
if (etaText) {
|
|
etaEl.hidden = false;
|
|
etaEl.textContent = etaText;
|
|
} else {
|
|
etaEl.hidden = true;
|
|
etaEl.textContent = "";
|
|
}
|
|
}
|
|
|
|
const jetztBox = document.getElementById("jetzt-box");
|
|
const jetztCount = document.getElementById("jetzt-count");
|
|
if (jetztCount) {
|
|
jetztCount.textContent = actionItems.length;
|
|
jetztCount.className = "count" + (actionItems.length ? " todo" : "");
|
|
}
|
|
if (jetztBox) {
|
|
jetztBox.classList.toggle("is-empty", !actionItems.length);
|
|
const jetztList = document.getElementById("jetzt-list");
|
|
if (!actionItems.length) {
|
|
setListHtml(jetztList, '<li class="queue-empty">Alles erledigt — nichts zu tun.</li>');
|
|
} else {
|
|
setListHtml(jetztList, actionItems.map(actionItemHtml).join(""));
|
|
}
|
|
}
|
|
|
|
const runBox = document.getElementById("run-box");
|
|
if (runBox) {
|
|
runBox.hidden = !running.length;
|
|
setListHtml(
|
|
document.getElementById("run-list"),
|
|
running.map((p) => runLineHtml({
|
|
name: p.name || p.host,
|
|
host: p.host,
|
|
progress: p.progress,
|
|
remain: p.remain,
|
|
filename: p.filename,
|
|
tipExtra: (p.starting || p.state === "starting") ? "wird gestartet" : "",
|
|
cancelId: p.queue_id,
|
|
})).join(""),
|
|
);
|
|
}
|
|
|
|
const sends = data.sends || [];
|
|
const activeSends = sends.filter((j) => j.status === "pending" || j.status === "running");
|
|
const sendBox = document.getElementById("send-box");
|
|
if (sendBox) {
|
|
sendBox.hidden = !activeSends.length && !sends.some((j) => j.status === "error");
|
|
const sendCount = document.getElementById("send-count");
|
|
if (sendCount) sendCount.textContent = activeSends.length;
|
|
setListHtml(
|
|
document.getElementById("send-list"),
|
|
sends.map((job) => {
|
|
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
|
const cls = job.status === "error" ? "bad" : "";
|
|
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
|
|
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
|
|
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
|
|
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
|
|
</li>`;
|
|
}).join(""),
|
|
);
|
|
}
|
|
|
|
const alerts = data.alerts || [];
|
|
const alertCount = data.unread_alerts || alerts.length;
|
|
const alertBox = document.getElementById("alert-box");
|
|
if (alertBox) {
|
|
alertBox.hidden = !alerts.length;
|
|
const alertCountEl = document.getElementById("alert-count");
|
|
if (alertCountEl) alertCountEl.textContent = alertCount;
|
|
setListHtml(
|
|
document.getElementById("alert-list"),
|
|
alerts.map((item) => `<li class="queue-item">
|
|
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
|
|
<span class="queue-msg">${escapeHtml(item.message)}</span>
|
|
</li>`).join(""),
|
|
);
|
|
}
|
|
|
|
renderLiveChips({
|
|
actionItems,
|
|
running,
|
|
activeSends: activeSends.length,
|
|
alertCount,
|
|
etaText,
|
|
});
|
|
|
|
const mobileBadge = document.getElementById("mobile-jetzt-badge");
|
|
if (mobileBadge) {
|
|
const n = actionItems.length || running.length || activeSends.length || alertCount;
|
|
mobileBadge.hidden = !n;
|
|
mobileBadge.textContent = String(actionItems.length || n);
|
|
mobileBadge.classList.toggle("todo", !!actionItems.length);
|
|
}
|
|
|
|
if (typeof window.onBedarfChanged === "function") {
|
|
window.onBedarfChanged({ shortfall: Array(data.bedarf_shortfall || 0).fill({}) });
|
|
}
|
|
|
|
bindLivePanelActions(document.getElementById("live-panel"));
|
|
}
|
|
|
|
function refreshPrintQueue() { return refreshJetzt(); }
|
|
function refreshSendQueue() { return refreshJetzt(); }
|
|
function refreshEvents() { return refreshJetzt(); }
|
|
function refreshTasks() { return refreshJetzt(); }
|
|
function refreshBedarf() { return refreshJetzt(); }
|
|
|
|
(function mobileMoreSheet() {
|
|
const sheet = document.getElementById("mobile-more-sheet");
|
|
const btn = document.getElementById("mobile-more");
|
|
if (!sheet || !btn) return;
|
|
btn.addEventListener("click", () => {
|
|
if (typeof sheet.showModal === "function") sheet.showModal();
|
|
});
|
|
sheet.addEventListener("click", (event) => {
|
|
const link = event.target.closest("a[href]");
|
|
if (!link) return;
|
|
event.preventDefault();
|
|
const href = link.getAttribute("href");
|
|
sheet.close();
|
|
if (href) location.href = href;
|
|
});
|
|
sheet.addEventListener("close", () => {
|
|
btn.setAttribute("aria-expanded", "false");
|
|
});
|
|
btn.addEventListener("click", () => btn.setAttribute("aria-expanded", "true"));
|
|
})();
|
|
if ("serviceWorker" in navigator) {
|
|
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
|
|
}
|
|
|
|
document.getElementById("events-clear")?.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
|
|
refreshJetzt();
|
|
if (typeof window.onEventsRead === "function") window.onEventsRead();
|
|
});
|
|
|
|
(function livePopClose() {
|
|
const bar = document.getElementById("live-bar");
|
|
const pop = document.getElementById("live-pop");
|
|
if (!bar || !pop) return;
|
|
document.addEventListener("click", (event) => {
|
|
if (pop.hidden) return;
|
|
if (bar.contains(event.target)) return;
|
|
closeLivePop();
|
|
});
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") closeLivePop();
|
|
});
|
|
})();
|
|
|
|
window.refreshPrintQueue = refreshPrintQueue;
|
|
window.refreshSendQueue = refreshSendQueue;
|
|
window.refreshBedarf = refreshBedarf;
|
|
window.refreshTasks = refreshTasks;
|
|
window.refreshJetzt = refreshJetzt;
|
|
refreshJetzt();
|
|
|
|
(function liveUpdates() {
|
|
let streamLive = false;
|
|
const sseKinds = {
|
|
queue: () => {
|
|
refreshJetzt();
|
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
},
|
|
senden: () => refreshJetzt(),
|
|
ereignis: () => refreshJetzt(),
|
|
aufgaben: () => refreshJetzt(),
|
|
drucker: () => {
|
|
refreshJetzt();
|
|
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
|
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
|
},
|
|
};
|
|
|
|
function whenStreamDown(fn) {
|
|
return async () => {
|
|
if (streamLive) return;
|
|
await fn();
|
|
};
|
|
}
|
|
|
|
if (typeof EventSource !== "undefined") {
|
|
let es;
|
|
let retryMs = 2000;
|
|
const retryMax = 60000;
|
|
let hadStream = false;
|
|
let retryTimer = 0;
|
|
|
|
function connectStream() {
|
|
if (es) {
|
|
try { es.close(); } catch (e) {}
|
|
}
|
|
es = new EventSource("/api/v1/stream", { withCredentials: true });
|
|
es.addEventListener("open", () => {
|
|
streamLive = true;
|
|
retryMs = 2000;
|
|
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
|
|
hadStream = true;
|
|
});
|
|
es.addEventListener("error", () => {
|
|
streamLive = false;
|
|
try { es.close(); } catch (e) {}
|
|
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
|
|
clearTimeout(retryTimer);
|
|
retryTimer = setTimeout(connectStream, retryMs);
|
|
retryMs = Math.min(retryMax, retryMs * 2);
|
|
});
|
|
Object.keys(sseKinds).forEach((kind) => {
|
|
es.addEventListener(kind, () => sseKinds[kind]());
|
|
});
|
|
es.addEventListener("ping", () => {});
|
|
}
|
|
connectStream();
|
|
window.addEventListener("pagehide", () => {
|
|
streamLive = false;
|
|
clearTimeout(retryTimer);
|
|
if (es) {
|
|
try { es.close(); } catch (e) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
createPoller(whenStreamDown(refreshJetzt), 8000).start();
|
|
})();
|
|
</script>
|
|
{% block script %}{% endblock %}
|
|
</body>
|
|
</html>
|