chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 45bc449ea0
53 changed files with 12329 additions and 0 deletions
@@ -0,0 +1,330 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zutaten-Kategorien</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<script src="/shared/boehmi-runner.js"></script>
<script src="/shared/boehmi-backups.js"></script>
<style>
.catrow { display: grid; grid-template-columns: 24px 1fr 200px; gap: 10px; align-items: center;
padding: 7px 11px; border-bottom: 1px solid var(--bt-line-soft); }
.catrow:last-child { border-bottom: none; }
.catrow.nocat { background: color-mix(in srgb, var(--bt-accent) 7%, transparent); }
.catrow select { width: 100%; padding: 5px 8px; font-size: 12.5px;
border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.catrow .cur { font-size: 11.5px; color: var(--bt-muted); }
.catfilter { width: 100%; box-sizing: border-box; margin-bottom: 10px; padding: 7px 10px;
font-size: 12.5px; border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.aibadge { display: inline-block; margin-left: 6px; padding: 1px 7px; font-size: 10.5px;
font-weight: 600; border-radius: 999px; color: var(--bt-accent);
background: color-mix(in srgb, var(--bt-accent) 14%, transparent); }
.list { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
@media (max-width: 640px) { .catrow { grid-template-columns: 24px 1fr; } .catrow select { grid-column: 2; } }
</style>
</head>
<body>
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>🏷 Zutaten-Kategorien</h1>
<p>Ordnet Zutaten ohne Supermarkt-Kategorie einer der vorhandenen
Kategorien zu — auf Wunsch per ChatGPT vorgeschlagen. Du gibst frei,
nichts wird automatisch geschrieben.</p>
</div>
<div id="warn" class="bt-notice err bt-hidden"></div>
<div class="bt-tabs" id="tabs">
<button class="bt-tab active" data-tab="pruefen">1 · Prüfen</button>
<button class="bt-tab" data-tab="vorschlagen">2 · Vorschlagen</button>
<button class="bt-tab" data-tab="uebernehmen">3 · Übernehmen</button>
<button class="bt-tab" data-tab="sicherungen">Sicherungen</button>
</div>
<!-- ═══════════════════════════════════════════════════ 1 Prüfen -->
<section id="tab-pruefen" class="tab">
<div class="bt-card">
<div class="bt-row" style="align-items:center">
<div style="flex:1">
<h2 style="margin:0">Bestandsaufnahme</h2>
<p class="hint" style="margin:4px 0 0">Liest die vorhandenen Kategorien
und alle Zutaten. Verändert nichts.</p>
</div>
<button class="primary" id="scan">Jetzt prüfen</button>
</div>
<div class="bt-row" style="align-items:center;margin-top:12px">
<label class="bt-inline">Plan: <select id="planFile"></select></label>
<button class="ghost" id="reload">Neu laden</button>
</div>
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="scanLog" class="bt-log bt-hidden"></div>
</div>
<div id="summary" class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>
</section>
<!-- ══════════════════════════════════════════════ 2 Vorschlagen -->
<section id="tab-vorschlagen" class="tab bt-hidden">
<div class="bt-card">
<h2>Einordnen per KI <small style="color:var(--bt-muted);font-weight:normal">(ChatGPT)</small></h2>
<p class="hint">Lässt ChatGPT jede noch nicht eingeordnete Zutat einer der
<b>vorhandenen</b> Kategorien zuordnen. Passt keine, bleibt die Zutat
offen. Nur ein Vorschlag — prüfen und freigeben unter „Übernehmen“.</p>
<label class="bt-check">
<input type="checkbox" id="alle"> auch bereits eingeordnete neu vorschlagen
</label>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button class="primary" id="prefill">Einordnen lassen</button>
<button class="ghost" id="probe">OpenAI testen</button>
<span class="bt-spacer"></span>
<span class="bt-badge" id="aiHint"></span>
</div>
<div id="aiStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="aiLog" class="bt-log bt-hidden"></div>
</div>
</section>
<!-- ═══════════════════════════════════════════════ 3 Übernehmen -->
<section id="tab-uebernehmen" class="tab bt-hidden">
<div id="box"><div class="bt-empty">Erst prüfen (Tab 1).</div></div>
<div class="bt-card" id="runCard" style="display:none">
<div class="bt-row" style="align-items:center">
<span id="count" class="bt-badge">0 Zuweisungen</span>
<span class="bt-spacer"></span>
<button id="dry">Trockenübung</button>
<button class="primary" id="apply">Zuweisen</button>
</div>
<div id="runStatus" class="bt-status bt-hidden">Bereit.</div>
<div id="runLog" class="bt-log bt-hidden"></div>
</div>
</section>
<!-- ═════════════════════════════════════════════ Sicherungen -->
<section id="tab-sicherungen" class="tab bt-hidden"></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
let state = { plans: [], tandoor: false, openai: false };
let plan = null;
/* ------------------------------------------------------------ Reiter */
$("tabs").addEventListener("click", (e) => {
const b = e.target.closest("button[data-tab]");
if (!b) return;
document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("active"));
document.querySelectorAll("main > .tab").forEach((x) => x.classList.add("bt-hidden"));
b.classList.add("active");
$(`tab-${b.dataset.tab}`).classList.remove("bt-hidden");
if (b.dataset.tab === "sicherungen") backupsView.reload();
});
function goTab(name) {
const b = document.querySelector(`#tabs button[data-tab="${name}"]`);
if (b) b.click();
}
function makeRunner(endpoint, logId, statusId, after) {
return BT.Runner({
endpoint, log: $(logId), status: $(statusId),
onStart: () => { $(logId).classList.remove("bt-hidden"); $(statusId).classList.remove("bt-hidden"); },
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
if (after) after(info);
},
});
}
const scanRunner = makeRunner("/api/run/pruefen", "scanLog", "scanStatus",
() => load(true).then(() => goTab("uebernehmen")));
let lastApply = false;
const runRunner = makeRunner("/api/run/anwenden", "runLog", "runStatus", () => {
if (lastApply) { lastApply = false; scanRunner.start({}); } else load();
});
const prefillRunner = makeRunner("/api/run/vorschlagen", "aiLog", "aiStatus", () => {
const cur = $("planFile").value; if (cur) loadPlan(cur).then(() => goTab("uebernehmen"));
});
const probeRunner = makeRunner("/api/run/probe", "aiLog", "aiStatus");
const restoreRunner = BT.Runner({
endpoint: "/api/run/restore",
onFinish: (info) => {
BT.toast(info.status === "done" ? "Fertig." : "Beendet: " + info.status,
info.status === "done" ? "ok" : "err");
backupsView.reload(); load(true);
},
});
const backupsView = BT.Backups({
mount: $("tab-sicherungen"), runner: restoreRunner, detail: true,
note: `Kategorie-Zuweisungen sind nicht destruktiv und werden beim
Zurückspielen exakt auf den vorherigen Stand zurückgesetzt.`,
});
$("scan").addEventListener("click", () => scanRunner.start({}));
$("reload").addEventListener("click", () => load());
$("planFile").addEventListener("change", (e) => loadPlan(e.target.value));
$("prefill").addEventListener("click", () => {
const p = $("planFile").value;
if (!p) { BT.toast("Erst prüfen.", "err"); return; }
$("aiStatus").classList.remove("bt-hidden"); $("aiLog").classList.remove("bt-hidden");
prefillRunner.start({ plan: p, alle: $("alle").checked });
});
$("probe").addEventListener("click", () => {
$("aiStatus").classList.remove("bt-hidden"); $("aiLog").classList.remove("bt-hidden");
probeRunner.start({});
});
$("dry").addEventListener("click", () => run(false));
$("apply").addEventListener("click", () => run(true));
$("box").addEventListener("change", updateCount);
function renderSummary() {
if (!plan) { $("summary").innerHTML = `<div class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>`; return; }
const ohne = plan.foods.filter((f) => !f.current_id).length;
$("summary").innerHTML = `
<div class="bt-card">
<div class="bt-row" style="gap:16px;flex-wrap:wrap">
<span class="bt-badge">${plan.foods.length} Zutaten</span>
<span class="bt-badge ${ohne ? "warn" : "ok"}">${ohne} ohne Kategorie</span>
<span class="bt-badge">${plan.categories.length} Kategorien</span>
</div>
<p class="hint" style="margin:10px 0 0">${ohne
? `Weiter zu <b>Vorschlagen</b> (KI ordnet ein) oder direkt zu <b>Übernehmen</b>.`
: `Alle Zutaten haben bereits eine Kategorie.`}</p>
</div>`;
}
function render() {
renderSummary();
const boxEmpty = `<div class="bt-empty">Erst prüfen (Tab 1).</div>`;
if (!plan) { $("box").innerHTML = boxEmpty; $("runCard").style.display = "none"; return; }
const cats = plan.categories || [];
const ohne = plan.foods.filter((f) => !f.current_id).length;
const opts = (sel) => `<option value=""${sel ? "" : " selected"}>— keine —</option>` +
cats.map((c) => `<option value="${c.id}"${sel === c.id ? " selected" : ""}>${BT.escape(c.name)}</option>`).join("");
$("box").innerHTML = `
<div class="bt-card">
<h2>Zutaten <small style="color:var(--bt-muted);font-weight:normal">
(${ohne} ohne Kategorie von ${plan.foods.length})</small></h2>
<p class="hint">Wähle je Zutat eine Kategorie und hake sie an. Geschrieben
wird nur, was du anhakst und was sich vom Ist-Stand unterscheidet.</p>
<input type="text" class="catfilter" id="catfilter" placeholder="filtern nach Name …">
<div class="list" id="catlist">${plan.foods.map((f) => `
<div class="catrow${f.current_id ? "" : " nocat"}" data-name="${BT.escape((f.name||"").toLowerCase())}">
<input type="checkbox" data-aid="${f.aid}" data-role="accept" ${f.accept ? "checked" : ""}>
<div>
<div>${BT.escape(f.name)} <span class="cur">[${f.id}]</span>
${f.ai ? `<span class="aibadge">KI</span>` : ""}</div>
<div class="cur">aktuell: ${f.current_name ? BT.escape(f.current_name) : "—"}</div>
</div>
<select data-aid="${f.aid}" data-role="cat">${opts(f.proposed_id)}</select>
</div>`).join("")}</div>
</div>`;
$("catfilter").addEventListener("input", (e) => {
const q = e.target.value.trim().toLowerCase();
$("catlist").querySelectorAll(".catrow").forEach((row) => {
row.style.display = (!q || (row.dataset.name || "").includes(q)) ? "" : "none";
});
});
$("catlist").addEventListener("change", (e) => {
if (e.target.dataset && e.target.dataset.role === "cat") {
const acc = $("catlist").querySelector(`[data-aid="${e.target.dataset.aid}"][data-role="accept"]`);
if (acc) acc.checked = true;
}
});
$("runCard").style.display = "";
updateCount();
}
function collect() {
const rows = {};
document.querySelectorAll("#catlist [data-aid]").forEach((el) => {
const aid = el.dataset.aid;
rows[aid] = rows[aid] || { aid };
if (el.dataset.role === "accept") rows[aid].accept = el.checked;
if (el.dataset.role === "cat") rows[aid].proposed_id = el.value ? Number(el.value) : null;
});
return Object.values(rows);
}
function updateCount() {
if (!plan) return;
const byAid = Object.fromEntries(plan.foods.map((f) => [f.aid, f]));
const n = collect().filter((r) => {
const f = byAid[r.aid]; if (!f) return false;
return r.accept && (r.proposed_id ?? null) !== (f.current_id ?? null);
}).length;
$("count").textContent = `${n} Zuweisung${n === 1 ? "" : "en"}`;
$("apply").disabled = !n || !state.tandoor;
$("dry").disabled = !n;
}
async function save() {
const name = $("planFile").value;
if (!name) throw new Error("Kein Plan geladen.");
return BT.api(`/api/plans/${encodeURIComponent(name)}`, {
method: "POST", body: JSON.stringify({ accepted: collect() }),
});
}
async function run(apply) {
try {
const saved = await save();
if (!saved.zuweisungen) { BT.toast("Nichts anzuwenden.", "err"); return; }
if (apply && !confirm(`${saved.zuweisungen} Kategorie-Zuweisungen schreiben?\n\n` +
`Nicht destruktiv und über „Sicherungen“ rückspielbar.`)) return;
lastApply = apply;
runRunner.start({ plan: $("planFile").value, apply });
} catch (err) { BT.toast(err.message, "err"); }
}
async function loadPlan(name) {
if (!name) { plan = null; render(); return; }
plan = await BT.api(`/api/plans/${encodeURIComponent(name)}`);
render();
}
async function load(selectNewest = false) {
state = await BT.api("/api/state");
$("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;
const sel = $("planFile");
const previous = sel.value;
sel.innerHTML = state.plans.map((f) =>
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
if (state.plans.length) {
sel.value = (!selectNewest && state.plans.includes(previous)) ? previous : state.plans[0];
await loadPlan(sel.value);
} else { plan = null; render(); }
$("prefill").disabled = !state.openai;
$("aiHint").textContent = state.openai ? "" : "OpenAI-Key fehlt (Einstellungen)";
if (state.running) {
const lbl = state.running.label || "";
const r = lbl.startsWith("Kategorien prüfen") ? scanRunner
: lbl.startsWith("KI ordnet") ? prefillRunner
: lbl.startsWith("OpenAI") ? probeRunner
: lbl.includes("zurück") ? restoreRunner
: runRunner;
if (r === prefillRunner || r === probeRunner) {
$("aiStatus").classList.remove("bt-hidden"); $("aiLog").classList.remove("bt-hidden");
}
r.attach(state.running.id);
}
backupsView.reload();
}
load();
</script>
</body>
</html>