201 lines
8.5 KiB
HTML
201 lines
8.5 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Rezept-Inventur</title>
|
|
<link rel="stylesheet" href="/shared/boehmi.css">
|
|
<script src="/shared/boehmi.js"></script>
|
|
<script src="/shared/boehmi-runner.js"></script>
|
|
<style>
|
|
.facts { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 14px; }
|
|
.fact { background: var(--bt-surface); border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); padding: 13px 14px; box-shadow: var(--bt-shadow); }
|
|
.fact b { display: block; font-size: 24px; font-weight: 850; letter-spacing: -.03em; }
|
|
.fact span { font-size: 10.5px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--bt-muted); }
|
|
.fact.err b { color: var(--bt-err); }
|
|
.fact.warn b { color: var(--bt-warn); }
|
|
.fact.ok b { color: var(--bt-ok); }
|
|
|
|
.checks { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 7px; }
|
|
.ck { display: grid; grid-template-columns: auto 1fr auto; gap: 8px; align-items: center;
|
|
padding: 8px 10px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
|
|
background: var(--bt-surface); cursor: pointer; }
|
|
.ck:hover { background: var(--bt-surface-2); }
|
|
.ck .t { font-size: 12px; font-weight: 600; line-height: 1.25; }
|
|
.ck .c { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
|
|
.ck.off { opacity: .45; }
|
|
|
|
.flist { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
|
|
.fitem { display: grid; grid-template-columns: 56px 1fr auto; gap: 10px; align-items: baseline;
|
|
padding: 9px 11px; border-bottom: 1px solid var(--bt-line-soft); font-size: 13px; }
|
|
.fitem:last-child { border-bottom: none; }
|
|
.fitem:hover { background: var(--bt-surface-2); }
|
|
.fitem .who { font-weight: 650; }
|
|
.fitem .det { color: var(--bt-muted); font-size: 12px; }
|
|
.fitem .id { font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
|
|
.sev { font-size: 9.5px; font-weight: 850; letter-spacing: .06em; text-transform: uppercase;
|
|
padding: 2px 6px; border-radius: 4px; text-align: center; }
|
|
.sev.err { color: var(--bt-err); background: var(--bt-err-soft); }
|
|
.sev.warn { color: var(--bt-warn); background: var(--bt-warn-soft); }
|
|
.sev.info { color: var(--bt-muted); background: var(--bt-bg-2); }
|
|
.grp { padding: 9px 11px; background: var(--bt-surface-2); border-bottom: 1px solid var(--bt-line);
|
|
font-size: 11px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); }
|
|
.grp .h { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--bt-muted); font-size: 11.5px; }
|
|
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .fitem { grid-template-columns: 52px 1fr; } .fitem .id { display: none; } }
|
|
</style>
|
|
</head>
|
|
<body data-bt-title="Rezept-Inventur" data-bt-icon="🔍">
|
|
|
|
<main class="bt-main wide">
|
|
<div class="bt-pagehead">
|
|
<h1>Rezept-Inventur</h1>
|
|
<p>Geht die Sammlung durch und meldet, was fehlt oder schief steht.
|
|
Dieses Werkzeug liest ausschließlich — es verändert in Tandoor nichts.</p>
|
|
</div>
|
|
|
|
<div id="warn" class="bt-notice err bt-hidden"></div>
|
|
|
|
<div class="bt-card">
|
|
<div class="bt-row" style="align-items:center">
|
|
<div style="flex:1">
|
|
<h2 style="margin:0">Durchsehen</h2>
|
|
<p class="hint" style="margin:4px 0 0">
|
|
Je Rezept eine Abfrage — bei vielen Rezepten dauert das einen Moment.</p>
|
|
</div>
|
|
<button class="primary" id="scan">Inventur starten</button>
|
|
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
|
|
</div>
|
|
<div id="status" class="bt-status bt-hidden">Bereit.</div>
|
|
<div id="log" class="bt-log bt-hidden"></div>
|
|
</div>
|
|
|
|
<div id="box" class="bt-empty">Noch keine Inventur gelaufen.</div>
|
|
</main>
|
|
|
|
<script>
|
|
"use strict";
|
|
const $ = (id) => document.getElementById(id);
|
|
let state = {};
|
|
let report = null;
|
|
const hidden = new Set();
|
|
let search = "";
|
|
|
|
const runner = BT.Runner({
|
|
endpoint: "/api/run/scan",
|
|
log: $("log"), status: $("status"),
|
|
onStart: () => {
|
|
$("log").classList.remove("bt-hidden");
|
|
$("status").classList.remove("bt-hidden");
|
|
$("cancel").disabled = false;
|
|
},
|
|
onFinish: (info) => {
|
|
$("cancel").disabled = true;
|
|
BT.toast(info.status === "done" ? "Inventur fertig." : "Beendet: " + info.status,
|
|
info.status === "done" ? "ok" : "err");
|
|
load();
|
|
},
|
|
});
|
|
$("scan").addEventListener("click", () => runner.start({ only: [], skip: [] }));
|
|
$("cancel").addEventListener("click", () => runner.cancel());
|
|
|
|
function render() {
|
|
if (!report) return;
|
|
const s = report.summary;
|
|
const aktive = report.checks.filter((c) => c.count > 0);
|
|
|
|
$("box").className = "";
|
|
$("box").innerHTML = `
|
|
<div class="facts">
|
|
<div class="fact"><b>${s.recipes_checked}</b><span>Rezepte geprüft</span></div>
|
|
<div class="fact ok"><b>${s.clean_recipes}</b><span>ohne Befund</span></div>
|
|
<div class="fact err"><b>${s.err}</b><span>Fehler</span></div>
|
|
<div class="fact warn"><b>${s.warn}</b><span>Warnungen</span></div>
|
|
<div class="fact"><b>${s.info}</b><span>Hinweise</span></div>
|
|
</div>
|
|
|
|
${s.unreadable?.length ? `<div class="bt-notice warn">
|
|
${s.unreadable.length} Rezepte konnten nicht gelesen werden und fehlen im Bericht.
|
|
</div>` : ""}
|
|
|
|
<section class="bt-card">
|
|
<h2>Was wurde gefunden</h2>
|
|
<p class="hint">Zum Ein- und Ausblenden anklicken.</p>
|
|
<div class="checks">${aktive.map((c) => `
|
|
<div class="ck ${hidden.has(c.key) ? "off" : ""}" data-ck="${c.key}" title="${BT.escape(c.hint)}">
|
|
<span class="sev ${c.severity}">${c.severity}</span>
|
|
<span class="t">${BT.escape(c.title)}</span>
|
|
<span class="c">${c.count}</span>
|
|
</div>`).join("") || `<span class="hint">Nichts gefunden — sauber.</span>`}</div>
|
|
</section>
|
|
|
|
<section class="bt-card">
|
|
<div class="bt-row" style="align-items:center;margin-bottom:9px">
|
|
<h2 style="margin:0;flex:1">Befunde</h2>
|
|
<input type="search" id="q" placeholder="Rezept oder Zutat …" style="max-width:230px" value="${BT.escape(search)}">
|
|
<a class="bt-btn mini ghost" href="${BT.url("/api/export.csv")}" download>CSV</a>
|
|
</div>
|
|
<div class="flist" id="list"></div>
|
|
<p class="hint" id="more" style="margin-top:8px"></p>
|
|
</section>`;
|
|
|
|
$("q").addEventListener("input", (e) => { search = e.target.value; draw(); });
|
|
document.querySelectorAll("[data-ck]").forEach((el) => el.addEventListener("click", () => {
|
|
const k = el.dataset.ck;
|
|
hidden.has(k) ? hidden.delete(k) : hidden.add(k);
|
|
el.classList.toggle("off", hidden.has(k));
|
|
draw();
|
|
}));
|
|
draw();
|
|
}
|
|
|
|
function draw() {
|
|
const q = search.toLowerCase();
|
|
const rows = report.findings.filter((f) =>
|
|
!hidden.has(f.check) &&
|
|
(!q || String(f.target).toLowerCase().includes(q) || f.detail.toLowerCase().includes(q)));
|
|
|
|
const byCheck = {};
|
|
rows.forEach((f) => (byCheck[f.check] ||= []).push(f));
|
|
|
|
let html = "";
|
|
let shown = 0;
|
|
for (const [key, items] of Object.entries(byCheck)) {
|
|
const meta = report.checks.find((c) => c.key === key);
|
|
html += `<div class="grp">${BT.escape(meta.title)} · ${items.length}
|
|
<span class="h">— ${BT.escape(meta.hint)}</span></div>`;
|
|
for (const f of items.slice(0, 60)) {
|
|
shown++;
|
|
html += `<div class="fitem">
|
|
<span class="sev ${f.severity}">${f.severity}</span>
|
|
<span><span class="who">${BT.escape(f.target)}</span>
|
|
<span class="det"> — ${BT.escape(f.detail)}</span></span>
|
|
<span class="id">${f.scope} ${f.target_id}</span>
|
|
</div>`;
|
|
}
|
|
if (items.length > 60) {
|
|
html += `<div class="fitem"><span></span>
|
|
<span class="det">… und ${items.length - 60} weitere. Die vollständige Liste steht in der CSV.</span>
|
|
<span></span></div>`;
|
|
}
|
|
}
|
|
$("list").innerHTML = html || `<div class="bt-empty" style="border:none">Nichts übrig — Filter zurücksetzen?</div>`;
|
|
$("more").textContent = `${rows.length} Befunde angezeigt, ${report.findings.length} insgesamt.`;
|
|
}
|
|
|
|
async function load() {
|
|
state = await BT.api("/api/state");
|
|
report = state.report;
|
|
$("warn").classList.toggle("bt-hidden", state.tandoor);
|
|
if (!state.tandoor) {
|
|
$("warn").innerHTML = `Tandoor-URL und Token fehlen. Bitte in den
|
|
<a href="/settings">Einstellungen</a> hinterlegen.`;
|
|
}
|
|
$("scan").disabled = !state.tandoor;
|
|
if (report) render();
|
|
if (state.running) runner.attach(state.running.id);
|
|
}
|
|
load();
|
|
</script>
|
|
</body>
|
|
</html>
|