maboto/src/angebote/web_static/index.html
Jeuner b63dad74a0 Frontend: API-Pfade pfad-agnostisch (BASE-Prefix) für Reverse-Proxy
Alle fetch('/api/...') laufen über api(p)=BASE+p; BASE aus location.pathname
(lokal '/', deployed z.B. '/angebote/'). Ermöglicht Betrieb hinter einem
nginx-Reverse-Proxy mit Unterpfad ohne 404. Lokal unverändert (E2E grün).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:52:24 +02:00

781 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Angebots-Übersicht</title>
<style>
:root {
/* warme Neutral-Skala (kein reines Grau) */
--bg:#f4f2ee; --panel:#ffffff; --panel-2:#faf9f6;
--ink:#1b1a17; --ink-2:#3c3a35; --muted:#76726b; --faint:#a39e95;
--line:#e5e0d8; --line-2:#efebe3;
--brand:#1f6f53; --brand-d:#185b44; --brand-weak:#e7f1ec;
--accent:#9a6a00; --warn:#9a6a00; --warn-bg:#fbf2dd; --warn-line:#ecd49a;
--err:#9c2a23; --err-bg:#fbeeec; --err-line:#e6b8b2;
--price:#11221b;
/* Spacing-Skala 4/8/12/16/24/32/48 */
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s6:24px; --s8:32px; --s12:48px;
--radius:14px; --radius-sm:9px;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,monospace;
--shadow-sm:0 1px 2px rgba(30,26,20,.05);
--shadow-md:0 6px 18px -6px rgba(30,26,20,.12);
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--ink);
font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
-webkit-font-smoothing:antialiased; letter-spacing:-.003em;
}
@media (prefers-reduced-motion: reduce) { * { transition-duration:.01ms !important; } }
header.top {
padding:var(--s8) var(--s6) var(--s6); border-bottom:1px solid var(--line);
background:var(--panel);
}
.top-inner { max-width:1180px; margin:0 auto; }
header.top .eyebrow {
font-size:12px; text-transform:uppercase; letter-spacing:.16em;
color:var(--brand); font-weight:700; margin:0 0 var(--s2);
}
header.top h1 {
margin:0; font-size:clamp(1.7rem,2.6vw,2.3rem); line-height:1.08;
letter-spacing:-.025em; font-weight:700;
}
header.top p { margin:var(--s3) 0 0; color:var(--muted); font-size:15px; max-width:68ch; text-wrap:pretty; }
.wrap { max-width:1180px; margin:0 auto; padding:var(--s8) var(--s6) var(--s12); }
/* Stufen-Anordnung */
.stages { display:grid; grid-template-columns:1fr; gap:var(--s6); }
.stage {
background:var(--panel); border:1px solid var(--line); border-radius:var(--radius);
box-shadow:var(--shadow-sm); overflow:hidden;
}
.stage.locked { opacity:.72; }
.stage-head {
display:flex; align-items:center; gap:var(--s4);
padding:var(--s4) var(--s6); border-bottom:1px solid var(--line-2);
background:linear-gradient(var(--panel),var(--panel-2));
}
.stage-num {
flex:none; width:30px; height:30px; border-radius:50%;
display:grid; place-items:center; font-weight:700; font-size:14px;
background:var(--brand); color:#fff; font-variant-numeric:tabular-nums;
}
.stage.locked .stage-num { background:var(--faint); }
.stage-titles { flex:1; min-width:0; }
.stage-titles h2 { margin:0; font-size:16px; font-weight:700; letter-spacing:-.01em; }
.stage-titles .sub { margin:2px 0 0; font-size:13px; color:var(--muted); }
.stage-flag {
flex:none; font-size:11px; font-weight:600; letter-spacing:.04em;
text-transform:uppercase; padding:3px 9px; border-radius:999px;
background:var(--brand-weak); color:var(--brand-d); border:1px solid #cfe3d9;
}
.stage-flag.llm { background:#f0ecf7; color:#5a4a87; border-color:#ddd3ee; }
.stage-body { padding:var(--s6); }
.row { display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end; }
.field { display:flex; flex-direction:column; gap:var(--s1); }
.field label { font-size:11px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); font-weight:600; }
input, select, button { font:inherit; }
input[type=text], input[type=password], select {
padding:10px 12px; border:1px solid var(--line); border-radius:var(--radius-sm);
background:#fff; color:var(--ink); transition:border-color .15s, box-shadow .15s;
}
input:focus, select:focus { outline:none; border-color:var(--brand); box-shadow:0 0 0 3px var(--brand-weak); }
input#plz { width:118px; font-variant-numeric:tabular-nums; font-size:18px; font-weight:600; letter-spacing:.04em; }
select#modell { min-width:280px; }
input#modellsuche { width:150px; }
button {
padding:11px 20px; border:1px solid var(--brand); border-radius:var(--radius-sm);
background:var(--brand); color:#fff; cursor:pointer; font-weight:600; min-height:44px;
transition:transform .15s ease, background .15s, box-shadow .15s; white-space:nowrap;
}
button:hover:not(:disabled) { background:var(--brand-d); transform:translateY(-1px); box-shadow:var(--shadow-md); }
button.ghost { background:#fff; color:var(--brand); }
button.ghost:hover:not(:disabled) { background:var(--brand-weak); color:var(--brand-d); }
button:disabled { opacity:.45; cursor:not-allowed; }
button.icon { padding:10px 13px; }
.hint { font-size:12.5px; color:var(--muted); margin-top:var(--s3); }
.hint.lock { color:var(--accent); display:flex; align-items:center; gap:6px; }
/* Roh-Stand-Anzeige */
.rohstand {
margin-top:var(--s4); padding:var(--s4); border:1px solid var(--line-2);
border-radius:var(--radius-sm); background:var(--panel-2);
}
.rohstand.ok { border-color:#cfe3d9; background:#f3f8f5; }
.rohstand .line1 { display:flex; flex-wrap:wrap; gap:6px 16px; align-items:baseline; }
.rohstand .ok-dot { color:var(--brand); font-weight:700; }
.rohstand b { color:var(--ink); }
.rohstand .meta { color:var(--muted); font-size:13px; }
.rohstand .haendler-tags { margin-top:var(--s3); display:flex; flex-wrap:wrap; gap:6px; }
.tag { font-size:12px; background:var(--brand-weak); color:var(--brand-d); padding:2px 9px; border-radius:999px; border:1px solid #d7e7df; }
details.rohliste { margin-top:var(--s3); }
details.rohliste > summary { cursor:pointer; font-size:13px; color:var(--brand-d); font-weight:600; user-select:none; padding:var(--s2) 0; }
.rohtable { width:100%; border-collapse:collapse; margin-top:var(--s2); font-size:13.5px; }
.rohtable th { text-align:left; font-weight:600; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.05em; padding:6px 10px; border-bottom:1px solid var(--line); }
.rohtable td { padding:7px 10px; border-bottom:1px solid var(--line-2); vertical-align:top; }
.rohtable tr:last-child td { border-bottom:none; }
.rohtable .t-preis { text-align:right; font-variant-numeric:tabular-nums; font-weight:600; white-space:nowrap; }
.rohtable .t-h { color:var(--muted); }
.rohtable .marke { color:var(--brand-d); font-weight:600; }
/* OpenRouter-Konfig (separater Bereich) */
.config {
border:1px dashed var(--line); border-radius:var(--radius); background:var(--panel-2);
margin:var(--s6) 0; overflow:hidden;
}
.config > summary {
cursor:pointer; user-select:none; list-style:none;
display:flex; align-items:center; gap:var(--s3);
padding:var(--s4) var(--s6); font-weight:700; font-size:15px;
}
.config > summary::-webkit-details-marker { display:none; }
.config > summary .chev { transition:transform .2s; color:var(--muted); }
.config[open] > summary .chev { transform:rotate(90deg); }
.config > summary .ico { font-size:14px; }
.config > summary .gewaehlt-chip {
margin-left:auto; font-size:12px; font-weight:600; color:var(--brand-d);
background:var(--brand-weak); border:1px solid #cfe3d9; border-radius:999px;
padding:2px 11px; max-width:46ch; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.config > summary .gilt { margin-left:var(--s3); font-weight:500; font-size:12px; color:var(--muted); }
.config-body { padding:0 var(--s6) var(--s6); border-top:1px solid var(--line-2); padding-top:var(--s4); }
.keyrow { width:100%; max-width:560px; }
.keyrow input { width:100%; }
/* Status / Fortschritt */
#status { margin:var(--s4) 0 0; min-height:0; font-size:14.5px; }
#status:empty { margin:0; }
.bar { height:8px; background:var(--line); border-radius:5px; overflow:hidden; margin-top:var(--s2); max-width:420px; }
.bar > i { display:block; height:100%; width:0; background:var(--brand); transition:width .25s; }
.err {
background:var(--err-bg); border:1px solid var(--err-line); color:#6e201b;
padding:var(--s4); border-radius:var(--radius-sm); white-space:pre-wrap;
font-family:var(--mono); font-size:13px; line-height:1.5;
}
.pending { color:var(--muted); }
/* Ergebnis (Stufe 2) */
#result { margin-top:var(--s8); }
.summary { display:flex; flex-wrap:wrap; gap:var(--s2) var(--s6); align-items:baseline; color:var(--muted); font-size:14px; margin-bottom:var(--s4); }
.summary b { color:var(--ink); }
.filters {
display:flex; flex-wrap:wrap; gap:var(--s4) var(--s6); align-items:end;
padding:var(--s4) var(--s6); background:var(--panel); border:1px solid var(--line);
border-radius:var(--radius); margin-bottom:var(--s6);
}
.check { flex-direction:row; align-items:center; gap:8px; }
.check label { text-transform:none; letter-spacing:0; font-size:14px; color:var(--ink); font-weight:400; }
section.grp { background:var(--panel); border:1px solid var(--line); border-radius:var(--radius); margin-bottom:var(--s3); overflow:hidden; box-shadow:var(--shadow-sm); }
section.grp > h3 {
margin:0; padding:var(--s3) var(--s6); font-size:15px; font-weight:700; display:flex; justify-content:space-between;
align-items:center; cursor:pointer; background:linear-gradient(var(--panel),var(--panel-2)); border-bottom:1px solid var(--line-2);
letter-spacing:-.01em;
}
section.grp > h3 .cnt { color:var(--muted); font-weight:600; font-variant-numeric:tabular-nums; font-size:13px; }
section.grp.zu ul { display:none; }
section.grp.zu > h3 { border-bottom:none; }
section.grp ul { list-style:none; margin:0; padding:var(--s1) 0; }
li.ang { display:grid; grid-template-columns:1fr auto; gap:2px var(--s4); padding:11px var(--s6); border-bottom:1px solid var(--line-2); align-items:baseline; }
li.ang:last-child { border-bottom:none; }
.ang .name { font-weight:600; }
.ang .name .marke { color:var(--brand-d); }
.ang .meta { grid-column:1; color:var(--muted); font-size:13px; }
.ang .meta .haendler { color:var(--ink-2); background:var(--brand-weak); padding:1px 7px; border-radius:5px; font-weight:500; }
.ang .preis { grid-column:2; grid-row:1; text-align:right; font-weight:700; color:var(--price); font-variant-numeric:tabular-nums; white-space:nowrap; }
.ang .grund { grid-column:2; grid-row:2; text-align:right; color:var(--muted); font-size:12px; white-space:nowrap; }
.ang.unsicher { background:var(--warn-bg); }
.badge { display:inline-block; font-size:11px; background:var(--warn-bg); color:var(--warn); border:1px solid var(--warn-line); border-radius:5px; padding:0 7px; margin-left:7px; font-weight:600; }
.leer { padding:var(--s3) var(--s6); color:var(--muted); font-style:italic; font-size:14px; }
.quelle { font-family:var(--mono); font-size:11px; color:var(--faint); }
footer.note { margin-top:var(--s6); padding-top:var(--s4); border-top:1px solid var(--line); color:var(--muted); font-size:13px; }
footer.note .haendler { color:var(--ink-2); }
footer.note .hinweis { display:block; margin-top:6px; font-style:italic; }
.sep { color:var(--faint); }
/* --- Ergebnis: sticky Header mit horizontalem Gruppen-Band --- */
header.ergebnis-header { position:sticky; top:0; z-index:5; background:var(--bg); padding-top:var(--s2); padding-bottom:var(--s3); margin-bottom:var(--s4); border-bottom:1px solid var(--line); }
header.ergebnis-header .summary { margin-bottom:var(--s3); }
header.ergebnis-header .filters { margin-bottom:var(--s3); }
nav.gruppenband { display:flex; gap:6px; overflow-x:auto; padding:2px 0; scrollbar-width:thin; }
.navitem {
flex:none; display:inline-flex; align-items:center; gap:7px; white-space:nowrap;
padding:6px 12px; border-radius:999px; min-height:0; cursor:pointer;
background:var(--panel); color:var(--ink-2); border:1px solid var(--line);
font-weight:500; font-size:13px;
transition:background .15s, color .15s, border-color .15s;
}
.navitem:hover { background:var(--panel-2); transform:none; box-shadow:none; color:var(--ink); border-color:var(--faint); }
.navitem.aktiv { background:var(--brand-weak); color:var(--brand-d); border-color:#cfe3d9; font-weight:600; }
.navitem .nav-cnt { font-variant-numeric:tabular-nums; color:var(--muted); font-size:12px; }
.navitem.aktiv .nav-cnt { color:var(--brand-d); }
.navitem .nav-uns { font-size:10px; color:var(--warn); border:1px solid var(--warn-line); background:var(--warn-bg); border-radius:5px; padding:0 5px; font-weight:700; }
.kfeedback { font-size:13px; color:var(--brand-d); font-weight:600; height:0; overflow:hidden; transition:height .2s; }
.kfeedback.an { height:22px; margin-top:var(--s2); }
/* Kategorie-Chip pro Angebot (zugleich Korrektur-Anker) */
.ang .gchip { display:inline-block; font-size:11px; color:var(--muted); background:var(--panel-2); border:1px solid var(--line); border-radius:5px; padding:0 7px; margin-left:7px; cursor:pointer; vertical-align:middle; }
.ang .gchip:hover { border-color:var(--brand); color:var(--brand-d); }
.ang.unsicher .gchip { border-color:var(--warn-line); background:var(--warn-bg); color:var(--warn); }
.ang .ksel { font-size:12px; padding:2px 6px; border:1px solid var(--brand); border-radius:6px; margin-left:7px; vertical-align:middle; }
</style>
</head>
<body>
<header class="top">
<div class="top-inner">
<p class="eyebrow">Ortskonkret · händlerübergreifend · belegt</p>
<h1>Angebots-Übersicht</h1>
<p>Zwei strikt getrennte Stufen: zuerst die Rohdaten <b>deterministisch</b> holen
und speichern (kein LLM, kein Key), danach erst per LLM in Produktgruppen
einordnen. Jedes Angebot ist belegt — kein Auffüllen, Unsicheres ist markiert.</p>
</div>
</header>
<div class="wrap">
<div class="stages">
<!-- ============ STUFE 1 ============ -->
<section class="stage" id="stage1">
<div class="stage-head">
<div class="stage-num">1</div>
<div class="stage-titles">
<h2>Rohdaten holen &amp; speichern</h2>
<p class="sub">Deterministischer Abruf für eine PLZ. Wird pro PLZ/Woche auf Platte gespeichert.</p>
</div>
<div class="stage-flag">deterministisch · kein LLM</div>
</div>
<div class="stage-body">
<div class="row">
<div class="field">
<label for="plz">PLZ</label>
<input id="plz" type="text" value="60487" inputmode="numeric" maxlength="5" />
</div>
<div class="field">
<label>&nbsp;</label>
<button id="rohholen">Rohdaten holen ▶</button>
</div>
</div>
<div id="rohstatus"></div>
<div id="rohstand"></div>
</div>
</section>
<!-- ============ LLM-KONFIG (separat) ============ -->
<details class="config" id="config">
<summary>
<span class="chev"></span>
<span class="ico"></span>
<span>LLM-Konfiguration</span>
<span class="gewaehlt-chip" id="gewaehltChip" title="aktuell gewähltes Modell"></span>
<span class="gilt">gilt für Stufe&nbsp;2</span>
</summary>
<div class="config-body">
<div class="row">
<div class="field">
<label for="anbieter">Anbieter</label>
<select id="anbieter">
<option value="openrouter">OpenRouter (Cloud)</option>
<option value="ollama">Ollama (lokal)</option>
</select>
</div>
<div class="field">
<label for="modell">Modell</label>
<select id="modell"><option>lädt…</option></select>
</div>
<div class="field" id="suchfeld">
<label for="modellsuche">Modell suchen</label>
<input id="modellsuche" type="text" placeholder="z. B. qwen" />
</div>
<div class="field">
<label>&nbsp;</label>
<button id="refresh" class="ghost icon" title="Modell-Liste aktualisieren">↻ aktualisieren</button>
</div>
</div>
<div class="row" style="margin-top:var(--s4)" id="keyzeile">
<div class="field keyrow">
<label for="key">API-Key (bleibt lokal, nur an deinen Server)</label>
<input id="key" type="password" placeholder="OPENROUTER_API_KEY — optional, falls nicht in der Server-Umgebung" autocomplete="off" />
</div>
</div>
<p class="hint" id="konfighint">Der Key wird ausschließlich für Stufe&nbsp;2 verwendet und nie in Stufe&nbsp;1 (Datenabruf) eingesetzt.</p>
</div>
</details>
<!-- ============ STUFE 2 ============ -->
<section class="stage locked" id="stage2">
<div class="stage-head">
<div class="stage-num">2</div>
<div class="stage-titles">
<h2>Kategorisieren</h2>
<p class="sub">Ordnet die gespeicherten Rohdaten per LLM in Produktgruppen. Verändert keine Angebotsdaten.</p>
</div>
<div class="stage-flag llm">LLM-Schritt</div>
</div>
<div class="stage-body">
<div class="row">
<div class="field">
<label>&nbsp;</label>
<button id="kategorisieren" disabled>Kategorisieren ▶</button>
</div>
</div>
<div class="hint lock" id="lockhint">🔒 Erst aktiv, sobald für die PLZ Rohdaten gespeichert sind (Stufe&nbsp;1).</div>
<div id="status"></div>
</div>
</section>
</div>
<!-- ============ ERGEBNIS ============ -->
<div id="result" hidden>
<header class="ergebnis-header">
<div class="summary" id="summary"></div>
<div class="filters">
<div class="field"><label for="fhaendler">Händler</label><select id="fhaendler"><option value="">alle</option></select></div>
<div class="field"><label for="ftext">Angebot suchen</label><input id="ftext" type="text" placeholder="Titel…" /></div>
<div class="field check"><input id="fsicher" type="checkbox" /><label for="fsicher">nur sichere</label></div>
</div>
<nav id="gruppennav" class="gruppenband" aria-label="Produktgruppen"></nav>
<div id="korrektur-feedback" class="kfeedback"></div>
</header>
<div id="gruppen"></div>
<footer class="note" id="footer"></footer>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
let DATEN = null; // kategorisiertes Ergebnis (Stufe 2)
let ROHDA = false; // ob für die aktuelle PLZ Rohdaten vorliegen
// Pfad-Basis: lokal "/", hinter einem Reverse-Proxy z.B. "/angebote/".
// Macht die API-Aufrufe pfad-agnostisch -- absolute /api/-Pfade würden unter
// einem Unterpfad sonst ins Leere zeigen (404).
const BASE = location.pathname.endsWith("/")
? location.pathname
: location.pathname.replace(/[^/]*$/, "");
const api = p => BASE + String(p).replace(/^\//, "");
function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
function preisFmt(p){ return p!=null ? p.toLocaleString("de-DE",{minimumFractionDigits:2,maximumFractionDigits:2})+" €" : "Preis fehlt"; }
function setRohStatus(html){ $("#rohstatus").innerHTML = html; }
function setStatus(html){ $("#status").innerHTML = html; }
// ----- Modelle (LLM-Konfig: OpenRouter ODER Ollama) -------------------------
function anbieter(){ return $("#anbieter").value; }
// Auswahl in localStorage merken/laden, damit Anbieter + Modell einen Reload
// überleben (statt auf den Default zurückzuspringen).
function merkeWahl(){
try {
localStorage.setItem("ang_anbieter", anbieter());
const m = $("#modell").value;
if (m) localStorage.setItem("ang_modell_" + anbieter(), m);
} catch(e){}
}
function gemerkterAnbieter(){ try { return localStorage.getItem("ang_anbieter"); } catch(e){ return null; } }
function gemerktesModell(ab){ try { return localStorage.getItem("ang_modell_" + ab); } catch(e){ return null; } }
// Hält das dauerhaft sichtbare "gewähltes Modell"-Chip im Konfig-Kopf aktuell --
// auch wenn das Panel zugeklappt ist -- und merkt die Wahl.
function setGewaehlt(){
const sel = $("#modell");
const opt = sel.options[sel.selectedIndex];
const m = (sel.value && opt && !opt.disabled) ? sel.value : null;
const ab = anbieter() === "ollama" ? "Ollama (lokal)" : "OpenRouter";
$("#gewaehltChip").textContent = m ? `${m} · ${ab}` : `kein Modell · ${ab}`;
merkeWahl();
}
async function ladeModelle(q=""){
const sel = $("#modell");
const ab = anbieter();
// Anbieter-spezifische UI: Ollama braucht weder Key noch Suche.
$("#keyzeile").style.display = ab === "ollama" ? "none" : "";
$("#suchfeld").style.display = ab === "ollama" ? "none" : "";
$("#konfighint").innerHTML = ab === "ollama"
? "Lokale Modelle über Ollama (localhost:11434) — kein Key, kein Netz. <b>Achtung:</b> kleine Modelle (39 B) liefern für diese Batch-Aufgabe oft kein zuverlässiges Tool-Calling — dann wird ehrlich alles als „Sonstiges/unsicher“ markiert statt geraten. Für brauchbare Ergebnisse ein starkes tool-Modell oder OpenRouter nutzen."
: "Der Key wird ausschließlich für Stufe 2 verwendet und nie in Stufe 1 (Datenabruf) eingesetzt.";
const url = ab === "ollama" ? "/api/ollama-modelle" : ("/api/modelle?q=" + encodeURIComponent(q));
sel.innerHTML = "<option>lädt…</option>";
try {
const r = await fetch(api(url));
if (!r.ok) throw new Error((await r.json()).detail || r.status);
const liste = await r.json();
sel.innerHTML = "";
if (!liste.length){
sel.innerHTML = ab === "ollama"
? "<option value=''>(kein lokales Modell — Ollama gestartet? 'ollama serve')</option>"
: "<option value=''>(keine Treffer)</option>";
setGewaehlt(); return;
}
for (const m of liste){
const o = document.createElement("option");
o.value = m.id;
o.dataset.frei = m.frei ? "1" : "0";
o.dataset.empf = m.empfohlen ? "1" : "0";
let tag;
if (ab === "ollama") tag = "lokal";
else if (m.empfohlen) tag = "★ empfohlen";
else tag = m.frei ? "FREE · oft gedrosselt" : "paid";
const tools = m.tools ? "" : " ⚠ kein tool-calling";
o.textContent = `${m.id} [${tag}]${tools}`;
if (!m.tools) o.disabled = true;
sel.appendChild(o);
}
// Vorauswahl: gemerktes Modell -- aber bei OpenRouter NICHT automatisch ein
// gedrosseltes Free-Modell (führt zu 429); dann lieber das empfohlene.
const opts = [...sel.options];
const gemerkt = gemerktesModell(ab);
const gemerktOpt = gemerkt ? opts.find(o => o.value === gemerkt && !o.disabled) : null;
const empfOpt = opts.find(o => o.dataset.empf === "1" && !o.disabled);
if (gemerktOpt && !(ab !== "ollama" && gemerktOpt.dataset.frei === "1")){
sel.value = gemerktOpt.value;
} else if (empfOpt){
sel.value = empfOpt.value;
} else {
const ok = opts.find(o => !o.disabled && o.value);
if (ok) sel.value = ok.value;
}
setGewaehlt();
} catch (e){
sel.innerHTML = `<option value=''>Modelle nicht abrufbar</option>`;
setGewaehlt();
}
}
// ----- Stufe 1: Rohdaten holen / prüfen -------------------------------------
function lockStage2(grund){
ROHDA = false;
$("#kategorisieren").disabled = true;
$("#stage2").classList.add("locked");
$("#lockhint").style.display = "flex";
if (grund) $("#lockhint").innerHTML = "🔒 " + grund;
}
function unlockStage2(){
ROHDA = true;
$("#kategorisieren").disabled = false;
$("#stage2").classList.remove("locked");
$("#lockhint").style.display = "none";
}
function zeigeRohstand(d, frisch){
const dt = d.abgerufen_am ? new Date(d.abgerufen_am).toLocaleString("de-DE") : "—";
const tags = (d.haendler||[]).map(h => `<span class="tag">${esc(h)}</span>`).join("");
const zeilen = (d.angebote||[]).map(a => `
<tr>
<td>${a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : ""}${esc(a.titel)}${a.menge?` <span class="t-h">· ${esc(a.menge)}</span>`:""}</td>
<td class="t-h">${esc(a.haendler)}</td>
<td class="t-preis">${preisFmt(a.preis)}</td>
</tr>`).join("");
$("#rohstand").innerHTML = `
<div class="rohstand ok">
<div class="line1">
<span class="ok-dot">✓ Rohdaten vorhanden</span>
<span>für PLZ <b>${esc(d.plz)}</b>${d.ort_name?` (${esc(d.ort_name)})`:""}</span>
<span class="meta">— <b>${d.anzahl}</b> Angebote · <b>${(d.haendler||[]).length}</b> Händler · geholt am ${esc(dt)}${frisch?" (gerade aktualisiert)":""}</span>
</div>
<div class="haendler-tags">${tags}</div>
${(d.angebote||[]).length ? `
<details class="rohliste">
<summary>Belegte Rohliste anzeigen (${d.angebote.length}) — ungruppiert</summary>
<table class="rohtable">
<thead><tr><th>Artikel</th><th>Händler</th><th style="text-align:right">Preis</th></tr></thead>
<tbody>${zeilen}</tbody>
</table>
</details>` : `<div class="hint">Quellen liefen, lieferten aber 0 Angebote — kein Auffüllen.</div>`}
</div>`;
unlockStage2();
}
async function pruefeRohstand(plz){
$("#rohstand").innerHTML = "";
lockStage2();
if (!/^\d{5}$/.test(plz)) return;
try {
const r = await fetch(api("/api/rohdaten/" + encodeURIComponent(plz)));
// Stale-Guard: zwischenzeitlich getippte/andere PLZ -> Antwort verwerfen.
if ($("#plz").value.trim() !== plz) return;
if (r.status === 404) { lockStage2("Noch keine Rohdaten für diese PLZ — Stufe 1 ausführen."); return; }
if (!r.ok) return;
const d = await r.json();
if ($("#plz").value.trim() !== plz) return;
zeigeRohstand(d, false);
} catch {}
}
async function holeRohdaten(){
const plz = $("#plz").value.trim();
if (!/^\d{5}$/.test(plz)) { setRohStatus(`<div class="err">Bitte eine 5-stellige PLZ eingeben.</div>`); return; }
$("#rohholen").disabled = true;
$("#result").hidden = true; DATEN = null;
setRohStatus(`<div class="pending">Hole Angebote für <b>${esc(plz)}</b> … (deterministisch, ohne LLM)</div>`);
try {
const r = await fetch(api("/api/rohdaten"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ plz })
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.status);
setRohStatus("");
zeigeRohstand(d, true);
} catch (e){
setRohStatus(`<div class="err">${esc(e.message)}</div>`);
lockStage2();
} finally {
$("#rohholen").disabled = false;
}
}
// ----- Stufe 2: Kategorisieren ----------------------------------------------
async function kategorisiere(){
if (!ROHDA) return;
const plz = $("#plz").value.trim();
$("#kategorisieren").disabled = true;
$("#result").hidden = true;
setStatus(`<div class="pending">Starte Kategorisierung …</div>`);
let job;
try {
const r = await fetch(api("/api/kategorisieren"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({
plz, modell: $("#modell").value, anbieter: anbieter(),
key: anbieter()==="openrouter" ? ($("#key").value || undefined) : undefined
})
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || r.status);
job = d.job_id;
} catch (e){
setStatus(`<div class="err">${esc(e.message)}</div>`); $("#kategorisieren").disabled = false; return;
}
const poll = setInterval(async () => {
let s;
try { s = await (await fetch(api("/api/lauf/" + job))).json(); }
catch { return; }
if (s.status === "laufend"){
if (s.total){
const pct = Math.round(100 * s.done / s.total);
setStatus(`<div>Kategorisiere … Batch <b>${s.done}/${s.total}</b>
<div class="bar"><i style="width:${pct}%"></i></div></div>`);
} else {
setStatus(`<div class="pending">Kategorisiere …</div>`);
}
return;
}
clearInterval(poll);
$("#kategorisieren").disabled = false;
if (s.status === "fehler"){ setStatus(`<div class="err">${esc(s.fehler)}</div>`); return; }
setStatus("");
DATEN = s.ergebnis;
render();
$("#result").scrollIntoView({behavior:"smooth", block:"start"});
}, 1000);
}
// ----- Ergebnis-Rendering ----------------------------------------------------
function render(){
const d = DATEN;
$("#result").hidden = false;
const modellTxt = d.modell
? ` <span class="sep">·</span> kategorisiert mit <b>${esc(d.modell)}</b>${d.anbieter?` <span class="t-h">(${esc(d.anbieter)})</span>`:""}`
: "";
const cacheTxt = (d.aus_cache != null)
? ` <span class="sep">·</span> <b>${d.aus_cache}</b> aus Cache · <b>${d.neu}</b> neu` : "";
$("#summary").innerHTML =
`Ort <b>${esc(d.ort_name || d.ort_plz)}</b> (PLZ ${esc(d.ort_plz)}) <span class="sep">·</span> <b>${d.anzahl}</b> Angebote <span class="sep">·</span>
Quellen: ${d.quellen.map(esc).join(", ") || "—"} <span class="sep">·</span> <b>${d.unsicher}</b> unsicher${cacheTxt}${modellTxt}`;
const fh = $("#fhaendler");
const aktuell = fh.value;
fh.innerHTML = "<option value=''>alle</option>" + d.haendler.map(h => `<option>${esc(h)}</option>`).join("");
fh.value = aktuell;
zeichneGruppen(); // baut Sektionen + füllt das Gruppen-Band mit den Counts
$("#footer").innerHTML =
`<span class="haendler"><b>Beobachtete Händler (belegt):</b> ${d.haendler.map(esc).join(", ")}.</span>` +
d.hinweise.map(h => `<span class="hinweis">${esc(h)}</span>`).join("");
}
function passt(a, fH, fT, nurSicher){
return (!fH || a.haendler === fH) &&
(!nurSicher || !a.unsicher) &&
(!fT || (a.titel + " " + (a.marke||"")).toLowerCase().includes(fT));
}
function zeichneGruppen(){
const d = DATEN;
const fH = $("#fhaendler").value;
const fT = $("#ftext").value.trim().toLowerCase();
const nurSicher = $("#fsicher").checked;
const box = $("#gruppen");
box.innerHTML = "";
const navDaten = [];
for (const g of d.gruppen){
const items = g.angebote.filter(a => passt(a, fH, fT, nurSicher));
const sec = document.createElement("section");
sec.className = "grp";
sec.dataset.grp = g.name;
const h = document.createElement("h3");
h.innerHTML = `<span>${esc(g.name)}</span><span class="cnt">${items.length}</span>`;
h.onclick = () => sec.classList.toggle("zu");
sec.appendChild(h);
if (!items.length){
const p = document.createElement("div"); p.className = "leer";
p.textContent = (fH || fT || nurSicher) ? "keine Treffer im Filter" : "keine Angebote";
sec.appendChild(p);
} else {
const ul = document.createElement("ul");
for (const a of items) ul.appendChild(zeile(a, g.name));
sec.appendChild(ul);
navDaten.push({ name:g.name, count:items.length, uns:items.filter(a=>a.unsicher).length });
}
box.appendChild(sec);
}
baueGruppenband(navDaten);
}
function baueGruppenband(navDaten){
const nav = $("#gruppennav");
nav.innerHTML = "";
if (!navDaten.length){ nav.innerHTML = `<span class="navitem" style="cursor:default;color:var(--muted)">keine Treffer</span>`; return; }
for (const g of navDaten){
const b = document.createElement("button");
b.className = "navitem"; b.dataset.grp = g.name;
const uns = g.uns ? ` <span class="nav-uns" title="${g.uns} unsicher">${g.uns}</span>` : "";
b.innerHTML = `<span>${esc(g.name)}</span><span class="nav-cnt">${g.count}</span>${uns}`;
b.onclick = () => springeZu(g.name);
nav.appendChild(b);
}
}
function springeZu(name){
let ziel = null;
for (const sec of document.querySelectorAll("#gruppen section.grp")){
if (sec.dataset.grp === name){ ziel = sec; break; }
}
if (ziel){ ziel.classList.remove("zu"); ziel.scrollIntoView({behavior:"smooth", block:"start"}); }
for (const b of document.querySelectorAll(".navitem")) b.classList.toggle("aktiv", b.dataset.grp === name);
}
function zeile(a, gruppe){
const li = document.createElement("li");
li.className = "ang" + (a.unsicher ? " unsicher" : "");
const marke = a.marke ? `<span class="marke">${esc(a.marke)}</span> ` : "";
const menge = a.menge ? ` <span class="sep">·</span> ${esc(a.menge)}` : "";
const gueltig = (a.gueltig_von || a.gueltig_bis)
? ` <span class="sep">·</span> gültig ${a.gueltig_von||"?"}${a.gueltig_bis||"?"}` : "";
const badge = a.unsicher ? `<span class="badge">unsicher</span>` : "";
li.innerHTML =
`<div class="name">${marke}${esc(a.titel)}${badge}<span class="gchip" title="Produktgruppe ändern">${esc(gruppe)}</span></div>` +
`<div class="preis">${preisFmt(a.preis)}</div>` +
`<div class="meta"><span class="haendler">${esc(a.haendler)}</span>${menge}${gueltig}
&nbsp;<span class="quelle" title="${esc(a.quelle)}">${esc((a.quelle||"").slice(0,42))}</span></div>` +
`<div class="grund">${a.grundpreis ? esc(a.grundpreis) : ""}</div>`;
const chip = li.querySelector(".gchip");
chip.onclick = () => oeffneKorrektur(chip, a, gruppe);
return li;
}
// ----- Korrektur: Produktgruppe manuell setzen -> Produkt-Cache --------------
function oeffneKorrektur(chip, a, vonGruppe){
const sel = document.createElement("select");
sel.className = "ksel";
for (const g of DATEN.gruppen.map(x => x.name)){
const o = document.createElement("option");
o.value = g; o.textContent = g;
if (g === vonGruppe) o.selected = true;
sel.appendChild(o);
}
chip.replaceWith(sel);
sel.focus();
let fertig = false;
sel.onchange = () => { fertig = true; korrigiere(a, vonGruppe, sel.value); };
sel.onblur = () => { setTimeout(() => { if (!fertig && DATEN) render(); }, 150); };
}
async function korrigiere(a, vonGruppe, zielGruppe){
if (zielGruppe === vonGruppe){ render(); return; }
try {
const r = await fetch(api("/api/korrektur"), {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({ titel:a.titel, marke:a.marke, gruppe:zielGruppe, plz:$("#plz").value.trim() })
});
if (!r.ok){ throw new Error((await r.json()).detail || r.status); }
const von = DATEN.gruppen.find(g => g.name === vonGruppe);
const ziel = DATEN.gruppen.find(g => g.name === zielGruppe);
if (von){ von.angebote = von.angebote.filter(x => x !== a); von.anzahl = von.angebote.length; }
a.unsicher = false;
if (ziel){ ziel.angebote.push(a); ziel.anzahl = ziel.angebote.length; }
DATEN.unsicher = DATEN.gruppen.reduce((s,g) => s + g.angebote.filter(x=>x.unsicher).length, 0);
render();
zeigeFeedback(`✓ „${a.titel}" → ${zielGruppe} gemerkt (auch im Cache)`);
} catch(e){
render();
zeigeFeedback(`Korrektur fehlgeschlagen: ${e.message}`, true);
}
}
function zeigeFeedback(txt, fehler){
const el = $("#korrektur-feedback");
el.textContent = txt;
el.style.color = fehler ? "var(--err)" : "var(--brand-d)";
el.classList.add("an");
clearTimeout(zeigeFeedback._t);
zeigeFeedback._t = setTimeout(() => el.classList.remove("an"), 2800);
}
// ----- Events ----------------------------------------------------------------
$("#rohholen").onclick = holeRohdaten;
$("#kategorisieren").onclick = kategorisiere;
$("#refresh").onclick = () => ladeModelle($("#modellsuche").value.trim());
$("#modellsuche").addEventListener("keydown", e => { if (e.key==="Enter") ladeModelle(e.target.value.trim()); });
$("#anbieter").addEventListener("change", () => ladeModelle()); // Anbieter umschalten -> Liste neu
$("#modell").addEventListener("change", setGewaehlt); // Auswahl -> sichtbares Chip aktualisieren
$("#plz").addEventListener("keydown", e => { if (e.key==="Enter") holeRohdaten(); });
let _plzTimer;
$("#plz").addEventListener("input", () => {
clearTimeout(_plzTimer);
const plz = $("#plz").value.trim();
_plzTimer = setTimeout(() => pruefeRohstand(plz), 350);
});
for (const id of ["#fhaendler","#ftext","#fsicher"])
$(id).addEventListener("input", () => DATEN && zeichneGruppen());
// ----- Init ------------------------------------------------------------------
// Bookmarkbar: ?plz=… [&anbieter=…] [&modell=…] [&auto=1]
(async function init(){
const _p = new URLSearchParams(location.search);
// Anbieter: URL-Param hat Vorrang, sonst die gemerkte Wahl, sonst Default.
const ab = _p.get("anbieter") || gemerkterAnbieter();
if (ab) $("#anbieter").value = ab;
await ladeModelle();
const m = _p.get("modell");
if (m){
const sel = $("#modell");
if (![...sel.options].some(o => o.value === m)){ // auch nicht-gelistetes zulassen
const o = document.createElement("option"); o.value = o.textContent = m;
sel.insertBefore(o, sel.firstChild);
}
sel.value = m; setGewaehlt();
}
if (_p.get("plz")) $("#plz").value = _p.get("plz").trim();
await pruefeRohstand($("#plz").value.trim());
if (_p.get("auto") && ROHDA) kategorisiere(); // Stufe 2 automatisch (wenn Rohdaten da)
})();
</script>
</body>
</html>