Files
boehmitools-main/plugins/tandoor-conversions/static/index.html
T
2026-07-24 21:37:03 +02:00

344 lines
15 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Einheiten-Umrechnungen</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>
.ucrow { display: grid; grid-template-columns: 24px 1fr; gap: 6px 10px;
padding: 9px 11px; border-bottom: 1px solid var(--bt-line-soft); align-items: center; }
.ucrow:last-child { border-bottom: none; }
.ucrow .head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.ucrow .conv { grid-column: 2; display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
font-size: 12.5px; }
.ucrow input[type=number] { width: 78px; padding: 4px 6px; font-size: 12.5px;
border: 1px solid var(--bt-line); border-radius: var(--bt-r-sm);
background: var(--bt-surface); color: var(--bt-ink); }
.ucrow .grund { grid-column: 2; font-size: 11.5px; color: var(--bt-muted); }
.ucfilter { 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; 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); }
.estimate { border-left: 3px solid color-mix(in srgb, var(--bt-accent) 60%, transparent);
background: color-mix(in srgb, var(--bt-accent) 8%, transparent);
padding: 10px 12px; border-radius: var(--bt-r-sm); margin: 0 0 12px; font-size: 12.5px; }
.list { border: 1px solid var(--bt-line); border-radius: var(--bt-r); overflow: hidden; }
</style>
</head>
<body>
<main class="bt-main wide">
<div class="bt-pagehead">
<h1>🔁 Einheiten-Umrechnungen</h1>
<p>Sorgt dafür, dass jede Zutat, die mit einer Nicht-Gramm-Einheit vorkommt,
eine Umrechnung nach Gramm bekommt. ChatGPT schätzt die Werte — du prüfst.</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 · Anlegen</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 Rezepte und vorhandene
Umrechnungen und prüft, welche Einheit noch nicht bei Gramm ankommt.
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>Werte per KI <small style="color:var(--bt-muted);font-weight:normal">(ChatGPT)</small></h2>
<p class="hint">ChatGPT schätzt, wie viel Gramm eine typische Menge wiegt
(z. B. „1 Stück Zwiebel ≈ 110 g“). Prüfen und freigeben unter „Anlegen“.</p>
<div class="bt-row" style="margin-top:12px;align-items:center">
<button class="primary" id="prefill">Werte vorschlagen</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 Anlegen -->
<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 Umrechnungen</span>
<span class="bt-spacer"></span>
<button id="dry">Trockenübung</button>
<button class="primary" id="apply">Anlegen</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: `Angelegte Umrechnungen werden beim Zurückspielen wieder gelöscht.`,
});
$("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 });
});
$("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);
$("box").addEventListener("input", updateCount);
function renderSummary() {
if (!plan) { $("summary").innerHTML = `<div class="bt-empty">Noch nicht geprüft. Oben auf „Jetzt prüfen“.</div>`; return; }
const n = (plan.candidates || []).length;
const nogram = !plan.gram_id;
$("summary").innerHTML = `
<div class="bt-card">
<div class="bt-row" style="gap:16px;flex-wrap:wrap">
<span class="bt-badge ${n ? "warn" : "ok"}">${n} ohne Weg zu Gramm</span>
<span class="bt-badge">${plan.existing_count} vorhandene Umrechnungen</span>
</div>
<p class="hint" style="margin:10px 0 0">${nogram
? `In Tandoor gibt es keine Einheit „g“. Bitte zuerst eine Gramm-Einheit anlegen.`
: n ? `Weiter zu <b>Vorschlagen</b> (KI schätzt die Gramm-Werte) oder direkt zu <b>Anlegen</b>.`
: `Alles hat bereits einen Weg zu Gramm.`}</p>
</div>`;
}
function render() {
renderSummary();
if (!plan) { $("box").innerHTML = `<div class="bt-empty">Erst prüfen (Tab 1).</div>`; $("runCard").style.display = "none"; return; }
const cs = plan.candidates || [];
const g = plan.gram_name || "g";
const nogram = !plan.gram_id;
$("box").innerHTML = `
<div class="bt-card">
<h2>Umrechnungen nach ${BT.escape(g)} <small style="color:var(--bt-muted);font-weight:normal">
(${cs.length} offen · ${plan.existing_count} vorhanden)</small></h2>
${nogram ? `<div class="estimate">In Tandoor gibt es keine Einheit „g“.
Bitte zuerst eine Gramm-Einheit anlegen — sonst kann nichts nach Gramm
umgerechnet werden.</div>` : `<div class="estimate">⚠️ Die KI-Werte sind
<b>Schätzungen</b> für typische Größen (z. B. „1 Zwiebel ≈ 110 g“).
Tandoor rechnet damit Rezepte und Einkaufslisten hoch — bitte vor dem
Anlegen prüfen und anpassen.</div>`}
<input type="text" class="ucfilter" id="ucfilter" placeholder="filtern nach Zutat …">
<div class="list" id="uclist">${cs.map((k) => {
const ba = k.base_amount ?? 1;
const gr = k.grams ?? "";
return `
<div class="ucrow" data-name="${BT.escape((k.food_name||"").toLowerCase())}">
<input type="checkbox" data-aid="${k.aid}" data-role="accept" ${k.accept ? "checked" : ""}>
<div class="head"><b>${BT.escape(k.food_name)}</b>
<span class="bt-muted" style="font-size:11.5px">[${k.food_id}]</span>
${k.ai ? `<span class="aibadge">KI</span>` : ""}
${k.quelle === "umrechnung" ? `<span class="bt-muted" style="font-size:11px">· aus vorhandener Umrechnung</span>` : ""}</div>
<div class="conv">
<input type="number" step="any" min="0" data-aid="${k.aid}" data-role="ba" value="${ba}">
<span>${BT.escape(k.unit.name)}</span>
<span>=</span>
<input type="number" step="any" min="0" data-aid="${k.aid}" data-role="grams" value="${gr}" placeholder="?">
<span>${BT.escape(g)}</span>
</div>
${k.reason ? `<div class="grund">KI: ${BT.escape(k.reason)}</div>` : ""}
</div>`;
}).join("")}</div>
</div>`;
$("ucfilter").addEventListener("input", (e) => {
const q = e.target.value.trim().toLowerCase();
$("uclist").querySelectorAll(".ucrow").forEach((row) => {
row.style.display = (!q || (row.dataset.name || "").includes(q)) ? "" : "none";
});
});
$("uclist").addEventListener("change", (e) => {
const t = e.target; if (!t.dataset) return;
if (["ba", "grams"].includes(t.dataset.role)) {
const acc = $("uclist").querySelector(`[data-aid="${t.dataset.aid}"][data-role="accept"]`);
if (acc) acc.checked = true;
}
});
$("runCard").style.display = "";
updateCount();
}
function collect() {
const rows = {};
document.querySelectorAll("#uclist [data-aid]").forEach((el) => {
const aid = el.dataset.aid;
rows[aid] = rows[aid] || { aid };
const r = el.dataset.role;
if (r === "accept") rows[aid].accept = el.checked;
if (r === "ba") rows[aid].base_amount = el.value;
if (r === "grams") rows[aid].grams = el.value;
});
return Object.values(rows);
}
function updateCount() {
if (!plan) return;
const n = collect().filter((r) =>
r.accept && Number(r.base_amount) > 0 && Number(r.grams) > 0).length;
$("count").textContent = `${n} Umrechnung${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.umrechnungen) { BT.toast("Nichts anzulegen.", "err"); return; }
if (apply && !confirm(`${saved.umrechnungen} Umrechnungen anlegen?\n\n` +
`Die Werte sind Schätzungen. Über „Sicherungen“ wieder löschbar.`)) 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("Umrechnungen prüfen") ? scanRunner
: lbl.startsWith("KI schlägt") ? 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>