chore: initial import
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user