566 lines
25 KiB
HTML
566 lines
25 KiB
HTML
<!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>
|