chore: initial import
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nährwerte vervollständigen</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>
|
||||
/* Nur Layout – alles Optische kommt aus /shared/boehmi.css */
|
||||
.facts { display: grid; grid-template-columns: repeat(4, 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.warn b { color: var(--bt-warn); }
|
||||
.fact.ok b { color: var(--bt-ok); }
|
||||
|
||||
.coverage { display: grid; gap: 9px; }
|
||||
.cov { display: grid; grid-template-columns: 130px 1fr 92px; gap: 10px; align-items: center; font-size: 13px; }
|
||||
.cov .nm { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.track { height: 9px; border-radius: 999px; background: var(--bt-bg-2); border: 1px solid var(--bt-line-soft); overflow: hidden; }
|
||||
.fill { height: 100%; background: var(--bt-accent); border-radius: 999px; transition: width .4s ease; }
|
||||
.fill.low { background: var(--bt-err); }
|
||||
.fill.mid { background: var(--bt-warn); }
|
||||
.cov .n { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; color: var(--bt-muted); }
|
||||
|
||||
.matrix { overflow: auto; max-height: 62vh; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
|
||||
table.mx { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
|
||||
table.mx th { position: sticky; top: 0; z-index: 2; background: var(--bt-surface-2); padding: 8px 7px; text-align: left; font-size: 10.5px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--bt-muted); border-bottom: 1px solid var(--bt-line); white-space: nowrap; }
|
||||
table.mx td { padding: 6px 7px; border-bottom: 1px solid var(--bt-line-soft); white-space: nowrap; }
|
||||
table.mx tr:hover td { background: var(--bt-surface-2); }
|
||||
td.v { text-align: right; font-family: var(--bt-mono); font-size: 11.5px; }
|
||||
td.v.gap { background: var(--bt-err-soft); color: var(--bt-err); font-weight: 700; }
|
||||
td.v.gap::after { content: "fehlt"; font-size: 9.5px; }
|
||||
.rname { font-weight: 600; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rn { text-align: right; font-family: var(--bt-mono); font-size: 11px; color: var(--bt-muted); }
|
||||
|
||||
.prop { display: grid; grid-template-columns: 26px 1fr auto; gap: 9px; align-items: center; padding: 9px 10px; border-bottom: 1px solid var(--bt-line-soft); }
|
||||
.prop:last-child { border-bottom: none; }
|
||||
.prop .pv { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.pv label { display: flex; align-items: center; gap: 3px; font-size: 11px; color: var(--bt-muted); }
|
||||
.pv input { width: 74px; padding: 4px 6px; font-size: 11.5px; font-family: var(--bt-mono); text-align: right; }
|
||||
.plist { max-height: 54vh; overflow: auto; border: 1px solid var(--bt-line); border-radius: var(--bt-r); }
|
||||
.runbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.chips { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.chip { cursor: pointer; user-select: none; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--bt-line); background: var(--bt-surface); font-size: 12px; font-weight: 650; }
|
||||
.chip.on { background: var(--bt-accent-soft); border-color: var(--bt-accent); color: var(--bt-accent); }
|
||||
.chip input { display: none; }
|
||||
@media (max-width: 900px) { .facts { grid-template-columns: repeat(2, 1fr); } .cov { grid-template-columns: 96px 1fr 72px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body data-bt-title="Nährwerte" data-bt-icon="🥑">
|
||||
|
||||
<main class="bt-main wide">
|
||||
<div class="bt-pagehead">
|
||||
<h1>Nährwerte vervollständigen</h1>
|
||||
<p>Findet Zutaten, bei denen Eigenschaften fehlen, lässt die Lücken
|
||||
schätzen und schreibt sie nach Prüfung zurück. Rezept-Nährwerte rechnet
|
||||
Tandoor danach selbst aus.</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 alle Zutaten und Eigenschaften. Verändert nichts.</p>
|
||||
</div>
|
||||
<button class="primary" id="scan">Jetzt prüfen</button>
|
||||
</div>
|
||||
<div id="scanStatus" class="bt-status bt-hidden">Bereit.</div>
|
||||
<div id="scanLog" class="bt-log bt-hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="reportBox" 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>Was soll geschätzt werden?</h2>
|
||||
<p class="hint">Ein Sprachmodell schätzt die fehlenden Werte je 100 g.
|
||||
Nach Tandoor geschrieben wird hier noch nichts.</p>
|
||||
|
||||
<label>Eigenschaften</label>
|
||||
<div class="chips" id="propChips"></div>
|
||||
|
||||
<label>Zutaten</label>
|
||||
<div class="chips">
|
||||
<span class="chip on" id="modeGaps" data-mode="gaps">nur Lücken</span>
|
||||
<span class="chip" id="modeUsed" data-mode="used">nur Lücken in benutzten Zutaten</span>
|
||||
<span class="chip" id="modeSel" data-mode="sel">Auswahl unten</span>
|
||||
</div>
|
||||
|
||||
<div class="bt-grid2">
|
||||
<div>
|
||||
<label for="limit">Höchstens so viele Zutaten (0 = alle)</label>
|
||||
<input type="number" id="limit" value="0" min="0" max="2000">
|
||||
</div>
|
||||
<div>
|
||||
<label for="model">Modell</label>
|
||||
<input type="text" id="model" value="">
|
||||
</div>
|
||||
</div>
|
||||
<label class="bt-check" style="margin-top:8px">
|
||||
<input type="checkbox" id="overwrite"> Auch vorhandene Werte neu schätzen
|
||||
</label>
|
||||
|
||||
<div class="runbar" style="margin-top:12px">
|
||||
<button class="primary" id="propose">Vorschläge holen</button>
|
||||
<button class="ghost" id="probe">Verbindung testen</button>
|
||||
<span class="bt-spacer"></span>
|
||||
<span class="bt-badge" id="costHint"></span>
|
||||
</div>
|
||||
<p class="hint" style="margin:8px 0 0">„Verbindung testen“ macht eine
|
||||
einzelne Beispiel-Abfrage und zeigt, ob das Modell erreichbar ist und
|
||||
antwortet — nützlich, wenn ein Lauf „ohne Ergebnis“ zurückkommt.</p>
|
||||
<div id="propStatus" class="bt-status bt-hidden">Bereit.</div>
|
||||
<div id="propLog" class="bt-log bt-hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="bt-card">
|
||||
<h2>Zutaten auswählen</h2>
|
||||
<div class="listhead bt-row" style="margin-bottom:9px">
|
||||
<input type="search" id="foodSearch" placeholder="Zutat suchen …" style="flex:1">
|
||||
<button class="mini ghost" id="selAllGaps">Alle mit Lücken</button>
|
||||
<button class="mini ghost" id="selNone">Keine</button>
|
||||
<span class="bt-badge accent" id="selCount">0</span>
|
||||
</div>
|
||||
<div class="matrix" id="foodPick"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════ 3 Übernehmen -->
|
||||
<section id="tab-uebernehmen" class="tab bt-hidden">
|
||||
<div class="bt-card">
|
||||
<div class="bt-row" style="align-items:flex-end">
|
||||
<div style="flex:1">
|
||||
<label for="proposalFile">Vorschlagsdatei</label>
|
||||
<select id="proposalFile"></select>
|
||||
</div>
|
||||
<button class="ghost" id="reload">Neu laden</button>
|
||||
</div>
|
||||
<p class="hint" id="proposalMeta" style="margin:8px 0 0"></p>
|
||||
</div>
|
||||
|
||||
<div class="bt-notice warn">
|
||||
Die Werte sind <b>Schätzungen eines Sprachmodells</b>, keine Laboranalysen.
|
||||
Für Hausgebrauch und Größenordnung gedacht. Bitte vor dem Übernehmen
|
||||
durchsehen — falsche Zahlen fallen später kaum noch auf.
|
||||
</div>
|
||||
|
||||
<div class="bt-card">
|
||||
<div class="bt-row" style="align-items:center;margin-bottom:8px">
|
||||
<h2 style="margin:0;flex:1">Vorschläge prüfen</h2>
|
||||
<button class="mini ghost" id="ackAll">Alle anhaken</button>
|
||||
<button class="mini ghost" id="ackNone">Keine</button>
|
||||
<span class="bt-badge accent" id="ackCount">0</span>
|
||||
</div>
|
||||
<div class="plist" id="proposalList"></div>
|
||||
|
||||
<div class="bt-grid2" style="margin-top:12px">
|
||||
<div>
|
||||
<label for="baseAmount">Bezugsmenge</label>
|
||||
<input type="number" id="baseAmount" value="100" min="1" step="1">
|
||||
</div>
|
||||
<div>
|
||||
<label for="baseUnit">Bezugseinheit (muss es in Tandoor geben)</label>
|
||||
<input type="text" id="baseUnit" value="g">
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Wird nur bei Zutaten gesetzt, die noch keine Bezugsmenge haben.</p>
|
||||
|
||||
<div class="runbar" style="margin-top:12px">
|
||||
<button id="dry">Trockenübung</button>
|
||||
<button class="primary" id="apply">Werte übernehmen</button>
|
||||
<span class="bt-spacer"></span>
|
||||
<button class="ghost mini" id="cancel" disabled>Abbrechen</button>
|
||||
</div>
|
||||
<div id="applyStatus" class="bt-status">Bereit.</div>
|
||||
<div id="applyLog" class="bt-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════ Sicherungen -->
|
||||
<section id="tab-sicherungen" class="tab bt-hidden"></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let state = {};
|
||||
let report = null;
|
||||
let proposal = null;
|
||||
const picked = new Set();
|
||||
const activeProps = new Set();
|
||||
let mode = "gaps";
|
||||
|
||||
/* ------------------------------------------------------------ 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();
|
||||
});
|
||||
|
||||
/* ------------------------------------------------- Läufe (3 Bereiche) */
|
||||
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/scan", "scanLog", "scanStatus", () => load());
|
||||
const propRunner = makeRunner("/api/run/propose", "propLog", "propStatus", () => load());
|
||||
const probeRunner = makeRunner("/api/run/probe", "propLog", "propStatus");
|
||||
const applyRunner = makeRunner("/api/run/apply", "applyLog", "applyStatus", () => { $("cancel").disabled = true; });
|
||||
|
||||
/* ------------------------------------------------------ Sicherungen */
|
||||
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();
|
||||
},
|
||||
});
|
||||
const backupsView = BT.Backups({
|
||||
mount: $("tab-sicherungen"),
|
||||
runner: restoreRunner,
|
||||
detail: true,
|
||||
note: `Zurückspielen setzt die Nährwerte auf den Stand vor dem Lauf —
|
||||
genau die Werte, die dieser Lauf gesetzt hat, verschwinden wieder.
|
||||
Was du seither von Hand geändert hast, wird erkannt und übersprungen.`,
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------- 1 · Bericht */
|
||||
function renderReport() {
|
||||
if (!report) return;
|
||||
const s = report.summary;
|
||||
const types = report.types;
|
||||
|
||||
$("reportBox").className = "";
|
||||
$("reportBox").innerHTML = `
|
||||
<div class="facts">
|
||||
<div class="fact"><b>${s.foods}</b><span>Zutaten</span></div>
|
||||
<div class="fact ok"><b>${s.percent_complete}%</b><span>vollständig</span></div>
|
||||
<div class="fact warn"><b>${s.incomplete}</b><span>mit Lücken</span></div>
|
||||
<div class="fact warn"><b>${s.incomplete_used_in_recipes}</b><span>Lücken in benutzten Zutaten</span></div>
|
||||
</div>
|
||||
|
||||
<section class="bt-card">
|
||||
<h2>Abdeckung je Eigenschaft</h2>
|
||||
<div class="coverage">${report.per_type.map((t) => {
|
||||
const cls = t.percent < 34 ? "low" : t.percent < 67 ? "mid" : "";
|
||||
return `<div class="cov">
|
||||
<span class="nm" title="${BT.escape(t.name)}">${BT.escape(t.name)}</span>
|
||||
<span class="track"><span class="fill ${cls}" style="width:${t.percent}%"></span></span>
|
||||
<span class="n">${t.percent}% · ${t.missing} offen</span>
|
||||
</div>`;
|
||||
}).join("")}</div>
|
||||
${s.without_base_amount ? `<p class="hint" style="margin-top:10px">
|
||||
${s.without_base_amount} Zutaten haben keine Bezugsmenge. Ohne die
|
||||
(z. B. „pro 100 g“) kann Tandoor keine Rezeptwerte rechnen — Schritt 3
|
||||
setzt sie mit.</p>` : ""}
|
||||
</section>
|
||||
|
||||
<section class="bt-card">
|
||||
<div class="bt-row" style="align-items:center;margin-bottom:9px">
|
||||
<h2 style="margin:0;flex:1">Wo genau fehlt was</h2>
|
||||
<input type="search" id="mxSearch" placeholder="filtern …" style="max-width:220px">
|
||||
<label class="bt-check"><input type="checkbox" id="onlyGaps" checked> nur Lücken</label>
|
||||
</div>
|
||||
<div class="matrix">
|
||||
<table class="mx">
|
||||
<thead><tr>
|
||||
<th>Zutat</th><th style="text-align:right">Rezepte</th>
|
||||
${types.map((t) => `<th style="text-align:right">${BT.escape(t.name)}${t.unit ? ` <span style="opacity:.6">${BT.escape(t.unit)}</span>` : ""}</th>`).join("")}
|
||||
</tr></thead>
|
||||
<tbody id="mxBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>`;
|
||||
|
||||
const draw = () => {
|
||||
const q = ($("mxSearch").value || "").toLowerCase();
|
||||
const only = $("onlyGaps").checked;
|
||||
const rows = report.foods.filter((r) =>
|
||||
(!only || !r.complete) && (!q || r.name.toLowerCase().includes(q)));
|
||||
$("mxBody").innerHTML = rows.slice(0, 400).map((r) => `
|
||||
<tr>
|
||||
<td class="rname" title="${BT.escape(r.name)}">${BT.escape(r.name)}</td>
|
||||
<td class="rn">${r.numrecipe}</td>
|
||||
${types.map((t) => {
|
||||
const v = r.values[String(t.id)];
|
||||
return v === null || v === undefined
|
||||
? `<td class="v gap"></td>`
|
||||
: `<td class="v">${Number(v).toLocaleString("de-DE")}</td>`;
|
||||
}).join("")}
|
||||
</tr>`).join("") || `<tr><td colspan="${types.length + 2}" style="padding:22px;text-align:center;color:var(--bt-muted)">Nichts gefunden.</td></tr>`;
|
||||
};
|
||||
$("mxSearch").addEventListener("input", draw);
|
||||
$("onlyGaps").addEventListener("change", draw);
|
||||
draw();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- 2 · Vorschlagen */
|
||||
function renderPropChips() {
|
||||
if (!report) { $("propChips").innerHTML = `<span class="hint">Erst prüfen.</span>`; return; }
|
||||
if (!activeProps.size) report.types.forEach((t) => activeProps.add(t.id));
|
||||
$("propChips").innerHTML = report.types.map((t) =>
|
||||
`<span class="chip ${activeProps.has(t.id) ? "on" : ""}" data-prop="${t.id}">${BT.escape(t.name)}</span>`
|
||||
).join("");
|
||||
}
|
||||
$("propChips").addEventListener("click", (e) => {
|
||||
const c = e.target.closest("[data-prop]");
|
||||
if (!c) return;
|
||||
const id = Number(c.dataset.prop);
|
||||
activeProps.has(id) ? activeProps.delete(id) : activeProps.add(id);
|
||||
renderPropChips(); updateCost();
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-mode]").forEach((c) => c.addEventListener("click", () => {
|
||||
mode = c.dataset.mode;
|
||||
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x === c));
|
||||
updateCost();
|
||||
}));
|
||||
|
||||
function targetFoods() {
|
||||
if (!report) return [];
|
||||
if (mode === "sel") return report.foods.filter((f) => picked.has(f.id));
|
||||
if (mode === "used") return report.foods.filter((f) => !f.complete && f.numrecipe > 0);
|
||||
return report.foods.filter((f) => !f.complete);
|
||||
}
|
||||
|
||||
function updateCost() {
|
||||
const n = targetFoods().length;
|
||||
const limit = Number($("limit").value) || 0;
|
||||
const eff = limit ? Math.min(n, limit) : n;
|
||||
$("costHint").textContent = eff
|
||||
? `${eff} Zutaten · etwa ${Math.ceil(eff / 20)} Anfragen`
|
||||
: "nichts ausgewählt";
|
||||
$("propose").disabled = !eff || !state.openai;
|
||||
}
|
||||
$("limit").addEventListener("input", updateCost);
|
||||
|
||||
function renderFoodPick() {
|
||||
if (!report) return;
|
||||
const q = ($("foodSearch").value || "").toLowerCase();
|
||||
const rows = report.foods.filter((f) => !q || f.name.toLowerCase().includes(q));
|
||||
$("foodPick").innerHTML = `<table class="mx"><thead><tr>
|
||||
<th style="width:30px"></th><th>Zutat</th><th style="text-align:right">Rezepte</th><th style="text-align:right">fehlt</th>
|
||||
</tr></thead><tbody>${rows.slice(0, 400).map((f) => `
|
||||
<tr>
|
||||
<td><input type="checkbox" data-food="${f.id}" ${picked.has(f.id) ? "checked" : ""}></td>
|
||||
<td class="rname">${BT.escape(f.name)}</td>
|
||||
<td class="rn">${f.numrecipe}</td>
|
||||
<td class="rn">${f.missing.length ? f.missing.length + " von " + report.types.length : "—"}</td>
|
||||
</tr>`).join("")}</tbody></table>`;
|
||||
$("selCount").textContent = picked.size;
|
||||
}
|
||||
$("foodPick").addEventListener("change", (e) => {
|
||||
const b = e.target.closest("[data-food]");
|
||||
if (!b) return;
|
||||
const id = Number(b.dataset.food);
|
||||
b.checked ? picked.add(id) : picked.delete(id);
|
||||
$("selCount").textContent = picked.size;
|
||||
updateCost();
|
||||
});
|
||||
$("foodSearch").addEventListener("input", renderFoodPick);
|
||||
$("selAllGaps").addEventListener("click", () => {
|
||||
report.foods.filter((f) => !f.complete).forEach((f) => picked.add(f.id));
|
||||
mode = "sel";
|
||||
document.querySelectorAll("[data-mode]").forEach((x) => x.classList.toggle("on", x.dataset.mode === "sel"));
|
||||
renderFoodPick(); updateCost();
|
||||
});
|
||||
$("selNone").addEventListener("click", () => { picked.clear(); renderFoodPick(); updateCost(); });
|
||||
|
||||
$("scan").addEventListener("click", () => scanRunner.start({}));
|
||||
$("propose").addEventListener("click", () => {
|
||||
const foods = mode === "sel" ? [...picked] : [];
|
||||
propRunner.start({
|
||||
foods,
|
||||
properties: [...activeProps],
|
||||
limit: Number($("limit").value) || 0,
|
||||
overwrite: $("overwrite").checked,
|
||||
model: $("model").value.trim() || null,
|
||||
});
|
||||
});
|
||||
$("probe").addEventListener("click", () => {
|
||||
$("propLog").classList.remove("bt-hidden");
|
||||
$("propStatus").classList.remove("bt-hidden");
|
||||
probeRunner.start({ model: $("model").value.trim() || null });
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------- 3 · Übernehmen */
|
||||
async function loadProposal(name) {
|
||||
if (!name) { proposal = null; $("proposalList").innerHTML = ""; $("proposalMeta").textContent = ""; return; }
|
||||
proposal = await BT.api(`/api/proposals/${encodeURIComponent(name)}`);
|
||||
const d = new Date(proposal.created_at);
|
||||
$("proposalMeta").textContent =
|
||||
`${proposal.proposals.length} Vorschläge · Modell ${proposal.model} · ${d.toLocaleString("de-DE")}` +
|
||||
(proposal.failed?.length ? ` · ${proposal.failed.length} ohne Ergebnis` : "");
|
||||
renderProposal();
|
||||
}
|
||||
|
||||
function emptyProposalNotice() {
|
||||
const n = proposal?.failed?.length || 0;
|
||||
const err = proposal?.error;
|
||||
if (!n && !err) {
|
||||
return `<div class="bt-empty" style="border:none">Diese Datei enthält keine Vorschläge.</div>`;
|
||||
}
|
||||
return `<div class="bt-notice warn" style="margin:0">
|
||||
<b>Diese Datei enthält keine übernehmbaren Vorschläge${n ? ` (${n} ohne Ergebnis)` : ""}.</b>
|
||||
Das deutet auf ein grundsätzliches Problem mit der Modell-Abfrage hin, nicht
|
||||
auf einzelne Zutaten.
|
||||
${err ? `<br><br>Erster Fehler der API:<br><code>${BT.escape(err)}</code>` : ""}
|
||||
<br><br>Prüf über <b>2 · Vorschlagen → „Verbindung testen“</b>, ob das Modell
|
||||
erreichbar ist und antwortet. Häufige Ursachen: falscher oder nicht
|
||||
freigeschalteter Modellname, fehlendes OpenAI-Guthaben, oder ein Modell, das
|
||||
bestimmte Aufruf-Parameter ablehnt.</div>`;
|
||||
}
|
||||
|
||||
function renderProposal() {
|
||||
if (!proposal) return;
|
||||
const names = Object.fromEntries(proposal.types.map((t) => [String(t.id), t]));
|
||||
$("proposalList").innerHTML = proposal.proposals.map((p) => `
|
||||
<div class="prop" data-row="${p.food_id}">
|
||||
<input type="checkbox" data-ack="${p.food_id}" ${p.accept !== false ? "checked" : ""}>
|
||||
<div>
|
||||
<b style="font-size:13px">${BT.escape(p.food_name)}</b>
|
||||
<span class="bt-muted" style="font-size:11px"> · ${p.numrecipe} Rezepte</span>
|
||||
</div>
|
||||
<div class="pv">
|
||||
${Object.entries(p.values).map(([tid, v]) => `
|
||||
<label>${BT.escape(names[tid]?.name || tid)}
|
||||
<input type="number" step="0.01" min="0" data-food="${p.food_id}" data-type="${tid}" value="${v}">
|
||||
<span>${BT.escape(names[tid]?.unit || "")}</span>
|
||||
</label>`).join("")}
|
||||
</div>
|
||||
</div>`).join("") || emptyProposalNotice();
|
||||
updateAck();
|
||||
}
|
||||
function updateAck() {
|
||||
const n = document.querySelectorAll("[data-ack]:checked").length;
|
||||
$("ackCount").textContent = `${n} angehakt`;
|
||||
$("apply").disabled = !n || !state.tandoor;
|
||||
$("dry").disabled = !n;
|
||||
}
|
||||
$("proposalList").addEventListener("change", updateAck);
|
||||
$("ackAll").addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = true); updateAck();
|
||||
});
|
||||
$("ackNone").addEventListener("click", () => {
|
||||
document.querySelectorAll("[data-ack]").forEach((c) => c.checked = false); updateAck();
|
||||
});
|
||||
$("proposalFile").addEventListener("change", (e) => loadProposal(e.target.value));
|
||||
$("reload").addEventListener("click", () => load());
|
||||
|
||||
async function saveEdits() {
|
||||
const rows = proposal.proposals.map((p) => {
|
||||
const values = {};
|
||||
document.querySelectorAll(`input[data-food="${p.food_id}"][data-type]`).forEach((i) => {
|
||||
values[i.dataset.type] = i.value;
|
||||
});
|
||||
return {
|
||||
food_id: p.food_id,
|
||||
accept: $(`[data-ack="${p.food_id}"]`)?.checked ?? true,
|
||||
values,
|
||||
};
|
||||
});
|
||||
return BT.api(`/api/proposals/${encodeURIComponent($("proposalFile").value)}`,
|
||||
{ method: "POST", body: JSON.stringify({ proposals: rows }) });
|
||||
}
|
||||
|
||||
async function runApply(apply) {
|
||||
try {
|
||||
const saved = await saveEdits();
|
||||
if (!saved.accepted) { BT.toast("Nichts angehakt.", "err"); return; }
|
||||
if (apply && !confirm(
|
||||
`${saved.accepted} Zutaten in Tandoor mit geschätzten Nährwerten füllen?\n\n` +
|
||||
`Vorhandene Werte bleiben unangetastet. Von jeder geänderten Zutat wird ` +
|
||||
`vorher eine Sicherung ins Laufverzeichnis geschrieben.`)) return;
|
||||
$("cancel").disabled = false;
|
||||
applyRunner.start({
|
||||
proposal: $("proposalFile").value,
|
||||
apply,
|
||||
overwrite: false,
|
||||
continue_on_error: true,
|
||||
base_amount: Number($("baseAmount").value) || 100,
|
||||
base_unit: $("baseUnit").value.trim() || "g",
|
||||
});
|
||||
} catch (err) { BT.toast(err.message, "err"); }
|
||||
}
|
||||
$("dry").addEventListener("click", () => runApply(false));
|
||||
$("apply").addEventListener("click", () => runApply(true));
|
||||
$("cancel").addEventListener("click", () => applyRunner.cancel());
|
||||
|
||||
/* ------------------------------------------------------------- Laden */
|
||||
async function load() {
|
||||
state = await BT.api("/api/state");
|
||||
report = state.report;
|
||||
|
||||
const fehlt = [];
|
||||
if (!state.tandoor) fehlt.push("Tandoor-URL und Token");
|
||||
if (!state.openai) fehlt.push("OpenAI-Key");
|
||||
$("warn").classList.toggle("bt-hidden", !fehlt.length);
|
||||
if (fehlt.length) {
|
||||
$("warn").innerHTML = `Es fehlt: ${fehlt.join(" und ")}. Bitte in den
|
||||
<a href="/settings">Einstellungen</a> hinterlegen.`;
|
||||
}
|
||||
$("scan").disabled = !state.tandoor;
|
||||
if (!$("model").value) $("model").value = state.model;
|
||||
|
||||
const sel = $("proposalFile");
|
||||
const previous = sel.value;
|
||||
sel.innerHTML = state.proposals.map((f) =>
|
||||
`<option value="${BT.escape(f)}">${BT.escape(f.replace(".json", ""))}</option>`).join("");
|
||||
if (state.proposals.length) {
|
||||
sel.value = state.proposals.includes(previous) ? previous : state.proposals[0];
|
||||
await loadProposal(sel.value);
|
||||
} else {
|
||||
$("proposalList").innerHTML = `<div class="bt-empty" style="border:none">Noch keine Vorschläge. Schritt 2.</div>`;
|
||||
}
|
||||
|
||||
if (report) { renderReport(); renderPropChips(); renderFoodPick(); }
|
||||
updateCost(); updateAck();
|
||||
|
||||
if (state.running) {
|
||||
const lbl = state.running.label || "";
|
||||
const r = lbl.startsWith("Prüfen") ? scanRunner
|
||||
: lbl.startsWith("Vorschlagen") ? propRunner
|
||||
: lbl.startsWith("OpenAI-Verbindung") ? probeRunner
|
||||
: lbl.startsWith("Zurückspielen") || lbl.startsWith("Trockenübung Zurück") ? restoreRunner
|
||||
: applyRunner;
|
||||
if (r === probeRunner) { $("propLog").classList.remove("bt-hidden"); $("propStatus").classList.remove("bt-hidden"); }
|
||||
r.attach(state.running.id);
|
||||
}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user