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>
781 lines
36 KiB
HTML
781 lines
36 KiB
HTML
<!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 & 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> </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 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> </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 2 verwendet und nie in Stufe 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> </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 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=>({"&":"&","<":"<",">":">",'"':"""}[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 (3–9 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}
|
||
<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>
|