KFE-Viewer: Web-Viewer für die ZVEH-Kalkulationshilfe

Python-Server ohne Abhängigkeiten, der die dBase/FoxPro-Dateien des
originalen KFE-Viewers (KFE.DBF, VORTEXT, GRUPPEN, Parameter, Muster-LVs)
direkt einliest, plus Single-Page-Frontend mit Live-Suche, Facettenfiltern,
einstellbarer Kalkulation (Lohnsätze, Materialzuschlag, DEL-Notiz),
Detailansicht und Merkliste mit CSV-Export.

Docker-Image enthält nur die App; die lizenzpflichtigen KFE-Daten werden
zur Laufzeit nach /data gemountet und sind nicht Teil des Repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-03 14:00:55 +02:00
commit a19558fc6c
10 changed files with 1404 additions and 0 deletions

955
index.html Normal file
View file

@ -0,0 +1,955 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>KFE Viewer</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f5fd1'/%3E%3Cpath d='M18 4 8 18h7l-2 10 10-14h-7z' fill='%23ffd23f'/%3E%3C/svg%3E">
<style>
:root{
--bg:#f5f6f8; --panel:#fff; --panel2:#f0f2f5; --hover:#f7f9fc;
--text:#1a1e24; --muted:#5b6470; --faint:#8a929d; --border:#e1e5ea;
--accent:#1f5fd1; --accent-weak:#e8effc; --accent-line:#bcd0f5;
--mark:#ffe68a; --mark-text:#1a1e24; --volt:#ffd23f;
--neg:#b4372b; --shadow:0 10px 40px rgba(16,24,40,.16);
color-scheme:light;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--bg:#0f1115; --panel:#161920; --panel2:#1c2029; --hover:#1a1e26;
--text:#e5e8ed; --muted:#9aa3af; --faint:#6c7480; --border:#272c36;
--accent:#6b9bf2; --accent-weak:#1a2740; --accent-line:#2c4677;
--mark:#6b5410; --mark-text:#fff3c4; --neg:#f08074; --shadow:0 10px 40px rgba(0,0,0,.5);
color-scheme:dark;
}
}
:root[data-theme="dark"]{
--bg:#0f1115; --panel:#161920; --panel2:#1c2029; --hover:#1a1e26;
--text:#e5e8ed; --muted:#9aa3af; --faint:#6c7480; --border:#272c36;
--accent:#6b9bf2; --accent-weak:#1a2740; --accent-line:#2c4677;
--mark:#6b5410; --mark-text:#fff3c4; --neg:#f08074; --shadow:0 10px 40px rgba(0,0,0,.5);
color-scheme:dark;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html,body{height:100%;margin:0}
body{font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;overflow:hidden}
button,input,select{font:inherit;color:inherit}
button{cursor:pointer}
input[type=text],input[type=search],select{background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:5px 8px;min-width:0;width:100%}
input:focus-visible,select:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
small{color:var(--muted)}
a{color:var(--accent)}
mark{background:var(--mark);color:var(--mark-text);border-radius:2px;padding:0 1px}
.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.link{background:none;border:0;padding:0;color:var(--accent);font-size:13px}
.link:hover{text-decoration:underline}
.btn{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 12px;border:1px solid var(--border);background:var(--panel);border-radius:8px;white-space:nowrap}
.btn:hover{border-color:var(--accent-line);background:var(--hover)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.icon-btn{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--border);background:var(--panel);border-radius:8px;flex:none}
.icon-btn:hover{background:var(--hover)}
.badge{min-width:20px;height:20px;padding:0 6px;border-radius:10px;background:var(--accent);color:#fff;font-size:12px;display:inline-grid;place-items:center}
/* Kopfzeile */
.top{display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--border);flex:none}
.brand{display:flex;gap:10px;align-items:center;flex:none}
.logo{width:34px;height:34px;border-radius:8px;background:var(--accent);display:grid;place-items:center}
.brand b{display:block;font-size:15px;letter-spacing:.2px}
.brand small{display:block;font-size:12px;white-space:nowrap}
.search{flex:1;position:relative;max-width:760px;margin-left:12px}
.search input{height:40px;border-radius:10px;background:var(--panel2);padding:0 44px 0 38px;font-size:15px}
.search input:focus{background:var(--panel)}
.search svg{position:absolute;left:12px;top:12px;color:var(--faint);pointer-events:none}
.search kbd{position:absolute;right:10px;top:10px;font:12px ui-monospace,monospace;color:var(--faint);border:1px solid var(--border);border-radius:5px;padding:1px 6px;background:var(--panel)}
.lt{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--muted);white-space:nowrap;cursor:pointer}
.spacer{flex:1}
/* Layout */
.layout{flex:1;display:grid;grid-template-columns:300px minmax(0,1fr);min-height:0}
body.has-detail .layout{grid-template-columns:300px minmax(0,1fr) minmax(400px,36vw)}
aside{overflow:auto;border-right:1px solid var(--border);background:var(--panel)}
main{display:flex;flex-direction:column;min-width:0;min-height:0}
/* Seitenleiste */
.box{border-bottom:1px solid var(--border)}
.box>summary{list-style:none;cursor:pointer;padding:12px 16px 8px;font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);display:flex;justify-content:space-between}
.box>summary::-webkit-details-marker{display:none}
.box>summary::after{content:"▾";transition:transform .15s}
.box:not([open])>summary::after{transform:rotate(-90deg)}
.boxin{padding:0 16px 14px}
.field{margin-bottom:12px}
.lbl{font-size:12px;color:var(--muted);margin-bottom:5px;display:flex;justify-content:space-between;gap:6px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.grid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}
.grid2 label,.grid3 label{font-size:11px;color:var(--muted);display:flex;flex-direction:column;gap:2px}
.grid2 input,.grid3 input{font-variant-numeric:tabular-nums;text-align:right}
.seg{display:inline-flex;border:1px solid var(--border);border-radius:7px;overflow:hidden;margin-bottom:6px}
.seg button{border:0;background:var(--panel);padding:4px 10px;font-size:13px}
.seg button+button{border-left:1px solid var(--border)}
.seg button.on{background:var(--accent);color:#fff}
.rowc{display:flex;gap:8px;align-items:flex-start}
.rowc .seg{margin:0}
.rowc input{width:70px;text-align:right}
.chk{display:flex;gap:7px;align-items:center;font-size:13px;margin-top:6px;cursor:pointer}
.hint{cursor:help;color:var(--faint)}
.kap,.gr{display:grid;grid-template-columns:24px 1fr auto;gap:6px;align-items:baseline;width:100%;text-align:left;border:0;background:none;padding:5px 8px;border-radius:6px;font-size:13px}
.kap:hover,.gr:hover{background:var(--hover)}
.kap.on{background:var(--accent-weak);color:var(--accent);font-weight:600}
.gr.on{background:var(--accent-weak);color:var(--accent);font-weight:600}
.kap.zero{opacity:.45}
.kap.all{grid-template-columns:1fr auto}
.kno{color:var(--faint);font-variant-numeric:tabular-nums;font-size:12px}
.kc{color:var(--faint);font-size:12px;font-variant-numeric:tabular-nums}
.grs{margin:2px 0 6px 18px;padding-left:6px;border-left:2px solid var(--accent-line)}
.gr{grid-template-columns:22px 1fr auto;font-size:12.5px;padding:4px 8px}
.fg{margin-bottom:12px}
.chipset{display:flex;flex-wrap:wrap;gap:5px}
.chip{border:1px solid var(--border);background:var(--panel);border-radius:14px;padding:2px 9px;font-size:12.5px;display:inline-flex;gap:5px;align-items:center}
.chip span{color:var(--faint);font-size:11.5px}
.chip:hover{border-color:var(--accent-line)}
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
.chip.on span{color:rgba(255,255,255,.8)}
/* Werkzeugleiste + Tabelle */
.toolbar{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;padding:9px 16px;border-bottom:1px solid var(--border);min-height:46px}
#count{font-size:13px;color:var(--muted);margin-right:4px}
#count b{color:var(--text)}
.achip{display:inline-flex;align-items:center;gap:4px;background:var(--accent-weak);color:var(--accent);border:1px solid var(--accent-line);border-radius:14px;padding:1px 4px 1px 10px;font-size:12.5px;max-width:320px}
.achip em{font-style:normal;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.achip button{border:0;background:none;color:inherit;width:20px;height:20px;border-radius:10px;padding:0;line-height:1}
.achip button:hover{background:var(--accent-line)}
.tablewrap{flex:1;overflow:auto;position:relative}
table.res{width:100%;border-collapse:separate;border-spacing:0}
.res th{position:sticky;top:0;z-index:1;background:var(--panel2);text-align:left;font-weight:600;font-size:12px;color:var(--muted);padding:8px 10px;border-bottom:1px solid var(--border);cursor:pointer;user-select:none;white-space:nowrap}
.res th.num{text-align:right}
.res th:hover{color:var(--text)}
.res th.sorted{color:var(--accent)}
.res th .arr{font-size:10px;margin-left:3px}
.res td{padding:7px 10px;border-bottom:1px solid var(--border);vertical-align:top;cursor:pointer}
.res tbody tr:hover td{background:var(--hover)}
.res tbody tr.sel td{background:var(--accent-weak)}
.res tbody tr.sel td:first-child{box-shadow:inset 3px 0 0 var(--accent)}
.res .pos{font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--muted);width:90px}
.res .kt{font-weight:500}
.res .zt{color:var(--muted);font-size:12.5px;margin-top:1px}
.res .me{color:var(--muted);width:44px}
.res .ep{font-weight:600}
.res .neg{color:var(--neg)}
.res td:last-child{width:40px;padding:4px 8px}
.add{width:26px;height:26px;border-radius:7px;border:1px solid var(--border);background:var(--panel);color:var(--accent);font-size:16px;line-height:1;opacity:.55}
tr:hover .add,.add:focus{opacity:1}
.add:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
.roh{font-style:normal;color:#c77d12;font-size:9px;vertical-align:super;margin-left:2px}
#empty{padding:60px 20px;text-align:center;color:var(--muted)}
#sentinel{height:1px}
/* Detail */
#detail{border-left:1px solid var(--border);background:var(--panel);display:flex;flex-direction:column;min-height:0}
.dh{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--border)}
.dpos{font-size:18px;font-weight:700;font-variant-numeric:tabular-nums;flex:1}
.dpos small{font-weight:400;font-size:12px;margin-left:6px}
.dbody{flex:1;overflow:auto;padding:14px 18px 20px}
.crumb{font-size:12.5px;color:var(--muted);margin-bottom:6px}
.crumb a{color:var(--muted);cursor:pointer;text-decoration:none}
.crumb a:hover{color:var(--accent);text-decoration:underline}
.dbody h2{font-size:17px;line-height:1.35;margin:0 0 10px}
.dbody h3{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin:22px 0 8px}
.lang{background:var(--panel2);border-radius:9px;padding:11px 13px;font-size:13.5px}
.lang .zus{margin-top:6px;font-weight:600}
.vtlink{margin-top:6px;display:inline-block}
table.calc,table.kv,table.cmp,table.mini{width:100%;border-collapse:collapse;font-size:13.5px}
.calc td{padding:4px 0;border-bottom:1px dashed var(--border)}
.calc td:last-child{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.calc tr.sum td{border-bottom:1px solid var(--text);font-weight:700;font-size:15px;padding-top:8px}
.calc tr.sub td{color:var(--muted)}
.calc tr.br td{font-weight:600;border-bottom:0}
.cmp th,.cmp td{padding:6px 6px;border:1px solid var(--border);text-align:right;font-variant-numeric:tabular-nums}
.cmp th{font-weight:500;font-size:12px;color:var(--muted);background:var(--panel2)}
.cmp td.on{background:var(--accent-weak);font-weight:700}
.kv td{padding:3px 0;vertical-align:top}
.kv td:first-child{color:var(--muted);width:42%}
.mini th,.mini td{padding:4px 4px;border-bottom:1px solid var(--border);text-align:left}
.mini th{font-size:12px;color:var(--muted);font-weight:500}
.mini .ttl td{font-weight:600;padding-top:10px}
.mini a{cursor:pointer}
.dfoot{display:flex;gap:8px;align-items:center;padding:10px 14px;border-top:1px solid var(--border);background:var(--panel)}
.dfoot input{width:80px;text-align:right}
/* Merkliste */
.backdrop{position:fixed;inset:0;background:rgba(10,14,20,.42);z-index:20}
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(900px,100vw);background:var(--panel);z-index:21;box-shadow:var(--shadow);display:flex;flex-direction:column}
.mlh{display:flex;gap:8px;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);flex-wrap:wrap}
.mlh h2{margin:0 8px 0 0;font-size:17px;flex:1}
.mlh select{width:auto;max-width:300px}
.mlbody{flex:1;overflow:auto}
.ml{width:100%;border-collapse:collapse;font-size:13.5px}
.ml th{position:sticky;top:0;background:var(--panel2);font-size:12px;color:var(--muted);font-weight:600;text-align:left;padding:7px 10px;border-bottom:1px solid var(--border)}
.ml td{padding:5px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
.ml th.num,.ml td.num{text-align:right}
.ml .ttl td{background:var(--panel2);font-weight:600}
.ml input{width:76px;text-align:right;padding:3px 6px}
.ml .p{white-space:nowrap;color:var(--accent);cursor:pointer}
.ml .del{border:0;background:none;color:var(--faint);font-size:16px;width:26px;height:26px;border-radius:6px}
.ml .del:hover{background:var(--panel2);color:var(--neg)}
.mlf{border-top:1px solid var(--border);padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px 16px;background:var(--panel2)}
.mlf div{font-size:12px;color:var(--muted)}
.mlf b{display:block;font-size:16px;color:var(--text);font-variant-numeric:tabular-nums}
.mlempty{padding:50px 20px;text-align:center;color:var(--muted)}
#toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,20px);background:var(--text);color:var(--bg);padding:8px 16px;border-radius:9px;font-size:13px;opacity:0;transition:.2s;pointer-events:none;z-index:40}
#toast.show{opacity:1;transform:translate(-50%,0)}
#loading{position:fixed;inset:0;display:grid;place-items:center;background:var(--bg);z-index:50;color:var(--muted);font-size:15px}
.only-mobile{display:none}
@media (max-width:1280px){
.layout,body.has-detail .layout{grid-template-columns:260px minmax(0,1fr)}
#detail{position:fixed;top:0;right:0;bottom:0;width:min(500px,100vw);z-index:15;box-shadow:var(--shadow)}
}
@media (max-width:1100px){
.c-lohn{display:none}
}
@media (max-width:860px){
.only-mobile{display:grid}
.top{gap:8px;padding:10px 12px}
.brand,.lt,.spacer{display:none}
.search{margin-left:0;min-width:0}
.search input{padding-right:12px;font-size:16px}
.search kbd{display:none}
.layout,body.has-detail .layout{grid-template-columns:minmax(0,1fr)}
aside{position:fixed;top:0;bottom:0;left:0;width:min(320px,88vw);z-index:22;transform:translateX(-102%);transition:transform .2s;box-shadow:var(--shadow)}
body.side-open aside{transform:none}
#detail{position:fixed;inset:0;z-index:15;border:0}
.c-mat{display:none}
.res td,.res th{padding-left:6px;padding-right:6px}
.res .pos{width:auto}
#btnML .lbltxt{display:none}
}
</style>
<!--KFE_DATA-->
</head>
<body>
<header class="top">
<button id="btnSide" class="icon-btn only-mobile" title="Filter &amp; Kalkulation" aria-label="Filter">☰</button>
<div class="brand">
<div class="logo"><svg width="20" height="20" viewBox="0 0 32 32" aria-hidden="true"><path d="M18 3 7 18h8l-2 11 11-15h-8z" fill="#ffd23f"/></svg></div>
<div><b>KFE Viewer</b><small id="meta">…</small></div>
</div>
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder='Suchen … z. B. nym 3x1,5 · 04.03 · steckdose -doppel · "in hohlwand"' aria-label="Suche">
<kbd>/</kbd>
</div>
<label class="lt" title="Auch die gemeinsamen Vortexte (Langtext) durchsuchen"><input type="checkbox" id="lt" checked><span>Langtext</span></label>
<div class="spacer"></div>
<button id="btnML" class="btn"><span class="lbltxt">Merkliste</span> <span id="mlCount" class="badge">0</span></button>
<button id="btnTheme" class="icon-btn" title="Farbschema: automatisch" aria-label="Farbschema">◐</button>
</header>
<div class="layout">
<aside id="side">
<details class="box" open>
<summary>Kalkulation</summary>
<div class="boxin">
<div class="field">
<div class="lbl">Stundenverrechnungssatz €/h <span class="hint" title="LG 10: Standard-Montage&#10;LG 11: u. a. Kap. 04.80–04.83 und 30 (Bustechnik)&#10;LG 12: Kap. 23–28 (IT-Installation, Drucker/Kopierer)&#10;Satz 1/2 = niedriger/höherer Satz der KFE">ⓘ</span></div>
<div class="seg" id="segLohn"><button data-v="1">Satz 1</button><button data-v="2">Satz 2</button></div>
<div class="grid3">
<label>LG 10<input type="text" inputmode="decimal" id="c_l10"></label>
<label>LG 11<input type="text" inputmode="decimal" id="c_l11"></label>
<label>LG 12<input type="text" inputmode="decimal" id="c_l12"></label>
</div>
</div>
<div class="field">
<div class="lbl">Materialzuschlag %</div>
<div class="rowc"><div class="seg" id="segZu"></div><input type="text" inputmode="decimal" id="c_zu"></div>
</div>
<div class="field">
<div class="lbl">Rohstoff – DEL-Notiz €/100 kg <span class="hint" title="Die Materialpreise basieren auf Cu 550 €/100 kg und Al 200 €/100 kg. Abweichende Notierungen ergeben einen Mehr-/Minderpreis: (DEL − Basis) / 100 × Cu-Zahl [kg/km] / 1000 je Einheit.">ⓘ</span></div>
<div class="grid2">
<label>Kupfer<input type="text" inputmode="decimal" id="c_cu"></label>
<label>Aluminium<input type="text" inputmode="decimal" id="c_al"></label>
</div>
<label class="chk"><input type="checkbox" id="c_bez"> +1 % Bezugskosten auf DEL</label>
<label class="chk" title="Feld ROHKORR der KFE (z. B. Verschnitt) – Bedeutung in den Unterlagen nicht dokumentiert"><input type="checkbox" id="c_korr"> Rohstoff-Korrekturwert einrechnen</label>
</div>
<div class="field grid2">
<label>Bauzeitfaktor<input type="text" inputmode="decimal" id="c_bz"></label>
<label>Preisanzeige<select id="c_br"><option value="0">netto</option><option value="1">brutto</option></select></label>
</div>
<button class="link" id="c_reset">Standardwerte wiederherstellen</button>
</div>
</details>
<details class="box" open>
<summary>Kapitel</summary>
<div class="boxin" id="chapters"></div>
</details>
<details class="box" open>
<summary>Filter</summary>
<div class="boxin">
<div id="facets"></div>
<div class="fg">
<div class="lbl">Bauzeit (min)</div>
<div class="grid2"><input type="text" inputmode="decimal" id="f_aeMin" placeholder="von"><input type="text" inputmode="decimal" id="f_aeMax" placeholder="bis"></div>
</div>
<div class="fg">
<div class="lbl">Einheitspreis (€)</div>
<div class="grid2"><input type="text" inputmode="decimal" id="f_epMin" placeholder="von"><input type="text" inputmode="decimal" id="f_epMax" placeholder="bis"></div>
</div>
</div>
</details>
</aside>
<main>
<div class="toolbar">
<span id="count"></span>
<span id="chips"></span>
<button class="link" id="reset" hidden>Alles zurücksetzen</button>
</div>
<div class="tablewrap" id="scroller">
<table class="res">
<thead><tr>
<th data-s="p">Pos.</th>
<th data-s="k">Kurztext</th>
<th data-s="me">ME</th>
<th data-s="ae" class="num" title="Bauzeit in Minuten">Zeit</th>
<th data-s="lohn" class="num c-lohn">Lohn</th>
<th data-s="mat" class="num c-mat">Material</th>
<th data-s="ep" class="num">EP <small id="epLbl">netto</small></th>
<th></th>
</tr></thead>
<tbody id="tb"></tbody>
</table>
<div id="sentinel"></div>
<div id="empty" hidden>Keine Positionen gefunden.<br><button class="link" id="reset2">Filter zurücksetzen</button></div>
</div>
</main>
<section id="detail" hidden></section>
</div>
<div id="mlBack" class="backdrop" hidden></div>
<div id="ml" class="drawer" hidden role="dialog" aria-label="Merkliste"></div>
<div id="toast"></div>
<div id="loading">Lade KFE-Daten …</div>
<script>
(async function () {
'use strict';
const $ = (s, e = document) => e.querySelector(s);
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const F2 = new Intl.NumberFormat('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
const FN = new Intl.NumberFormat('de-DE', {maximumFractionDigits: 3});
const FI = new Intl.NumberFormat('de-DE');
const coll = new Intl.Collator('de', {sensitivity: 'base', numeric: true});
const r2 = x => Math.round((x + (x < 0 ? -1e-9 : 1e-9)) * 100) / 100;
const pn = s => { const t = String(s ?? '').trim(); if (!t) return NaN;
const v = parseFloat(t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t); return isFinite(v) ? v : NaN; };
const store = {
get(k, d) { try { const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
};
const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
// ---------------------------------------------------------------- Theme
const themes = ['auto', 'light', 'dark'];
let theme = store.get('kfe.theme', 'auto');
function applyTheme() {
if (theme === 'auto') delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = theme;
$('#btnTheme').title = 'Farbschema: ' + ({auto: 'automatisch', light: 'hell', dark: 'dunkel'})[theme];
}
applyTheme();
$('#btnTheme').onclick = () => { theme = themes[(themes.indexOf(theme) + 1) % 3]; store.set('kfe.theme', theme); applyTheme(); };
// ---------------------------------------------------------------- Daten
let D;
try { D = window.KFE_DATA || await (await fetch('api/data')).json(); }
catch (e) { $('#loading').textContent = 'Daten konnten nicht geladen werden: ' + e; return; }
const P = D.meta.params, MEH = D.meta.meh, G = D.groups, VT = D.vortexte;
const stripTags = s => (s || '').replace(/<[^>]*>/g, '');
const norm = s => stripTags(s).toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/²/g, '2').replace(/³/g, '3').replace(/×/g, 'x').replace(/,/g, '.').replace(/\s+/g, ' ');
const VTN = {}; for (const k in VT) VTN[k] = norm(VT[k]);
const ROWS = D.positions, BYPOS = {}, VTCOUNT = {};
ROWS.forEach((r, i) => {
r.idx = i; r.kap = r.p.slice(0, 2); r.gr = r.p.slice(0, 5); r.yr = (r.d || '').slice(0, 4) || '–';
r.kt = stripTags(r.k) || r.i; r.zt = stripTags(r.z);
r.hK = norm(r.i + ' ' + r.k); r.hKc = r.hK.replace(/[\s\-]/g, ''); r.hZ = norm(r.z); r.hZc = r.hZ.replace(/[\s\-]/g, '');
r.rk = r.r.length ? [...new Set(r.r.map(x => x[0]))] : ['–'];
BYPOS[r.p] = r; if (r.v) VTCOUNT[r.v] = (VTCOUNT[r.v] || 0) + 1;
});
const KAPS = Object.keys(G).filter(k => k.length === 2).sort();
const GRS = {}; for (const g of Object.keys(G).filter(k => k.length === 5).sort()) (GRS[g.slice(0, 2)] ||= []).push(g);
const PROJ = {}; for (const p of D.projects) PROJ[p.nr] = p;
const fmtDate = d => d ? d.split('-').reverse().join('.') : '–';
$('#meta').textContent = `Ausgabe ${P.zeitraum || ''} · Stand ${fmtDate(P.stand)} · ${FI.format(ROWS.length)} Positionen`;
const meLbl = c => MEH[c] || c;
// ---------------------------------------------------------------- Kalkulation
const DEF = () => ({l10: P.lohn['1']['10'], l11: P.lohn['1']['11'], l12: P.lohn['1']['12'], zu: P.zuschlag[0],
cu: P.basis.CU ?? 550, al: P.basis.AL ?? 200, bez: false, korr: false, bz: P.bzfakt || 1, br: false});
let C = Object.assign(DEF(), store.get('kfe.calc', {}));
const rateOf = lg => lg === '11' ? C.l11 : lg === '12' ? C.l12 : C.l10;
function price(r, ov) {
ov = ov || {};
const rate = ov.lohn ?? rateOf(r.lg), zu = ov.zu ?? C.zu, ae = r.ae * C.bz;
const lohn = r2(ae * rate / 60);
let roh = 0;
for (const [art, gew, basis, korr] of r.r) {
const del = art === 'CU' ? C.cu : art === 'AL' ? C.al : basis;
roh += (del * (C.bez ? 1.01 : 1) - basis) / 100 * (gew + (C.korr ? korr : 0)) / 1000;
}
const matEK = r.mat + roh, mat = r2(matEK * (1 + zu / 100));
return {ae, rate, zu, lohn, roh, matEK, mat, ep: r2(lohn + mat)};
}
const vat = x => C.br ? x * (1 + P.mwst / 100) : x;
const money = x => F2.format(vat(x));
function recalc() { for (const r of ROWS) r.$ = price(r); }
// ---------------------------------------------------------------- Zustand / URL
const S = {q: '', lt: true, kap: '', gr: '', me: new Set(), lg: new Set(), roh: new Set(), yr: new Set(),
aeMin: '', aeMax: '', epMin: '', epMax: '', vt: '', sort: '', dir: 1, sel: ''};
const SETS = ['me', 'lg', 'roh', 'yr'], STRS = ['kap', 'gr', 'aeMin', 'aeMax', 'epMin', 'epMax', 'vt', 'sort', 'sel'];
function readHash() {
const h = new URLSearchParams(location.hash.slice(1));
S.q = h.get('q') || ''; S.lt = h.get('lt') !== '0'; S.dir = h.get('dir') === '-1' ? -1 : 1;
for (const k of SETS) S[k] = new Set((h.get(k) || '').split(',').filter(Boolean));
for (const k of STRS) S[k] = h.get(k) || '';
}
function writeHash() {
const h = new URLSearchParams();
if (S.q) h.set('q', S.q);
if (!S.lt) h.set('lt', '0');
for (const k of SETS) if (S[k].size) h.set(k, [...S[k]].join(','));
for (const k of STRS) if (S[k]) h.set(k, S[k]);
if (S.dir === -1) h.set('dir', '-1');
const s = h.toString();
history.replaceState(null, '', s ? '#' + s : location.pathname + location.search);
}
// ---------------------------------------------------------------- Suche
let TOKS = [], SC = null, lastQ = null, lastLt = null, HLRE = null, HLPOS = [];
function parseQ(q) {
const toks = [], re = /(-?)"([^"]*)"|(-?)(\S+)/g; let m;
while ((m = re.exec(q))) {
const neg = !!(m[1] || m[3]), t = norm(m[2] ?? m[4]).trim();
if (!t || t === '-') continue;
toks.push({neg, t, tc: t.replace(/[\s\-]/g, ''), pos: /^\d\d(\.\d{0,3}){1,2}$/.test(t)});
}
return toks;
}
function score(r) {
let s = 0;
for (const tk of TOKS) {
let h = 0;
if (tk.pos && (r.p.startsWith(tk.t) || r.a.startsWith(tk.t))) h = 100;
else if (r.hK.includes(tk.t) || (tk.tc.length > 2 && r.hKc.includes(tk.tc))) h = r.hK.startsWith(tk.t) ? 12 : 10;
else if (r.hZ.includes(tk.t) || (tk.tc.length > 2 && r.hZc.includes(tk.tc))) h = 5;
else if (S.lt && r.v && VTN[r.v].includes(tk.t)) h = 1;
if (tk.neg) { if (h) return -1; } else { if (!h) return -1; s += h; }
}
return s;
}
function runSearch() {
if (S.q === lastQ && S.lt === lastLt) return;
lastQ = S.q; lastLt = S.lt; TOKS = parseQ(S.q);
if (!TOKS.length) { SC = null; HLRE = null; HLPOS = []; return; }
SC = new Float64Array(ROWS.length);
for (const r of ROWS) SC[r.idx] = score(r);
const parts = TOKS.filter(t => !t.neg && !t.pos).map(t => t.t
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/ae/g, '(?:ae|ä)').replace(/oe/g, '(?:oe|ö)').replace(/ue/g, '(?:ue|ü)').replace(/ss/g, '(?:ss|ß)')
.replace(/\\\./g, '[.,]').replace(/ /g, '\\s+').replace(/x/g, '[x×]').replace(/2/g, '[2²]'));
HLRE = parts.length ? new RegExp(parts.sort((a, b) => b.length - a.length).join('|'), 'gi') : null;
HLPOS = TOKS.filter(t => !t.neg && t.pos).map(t => t.t);
}
function hl(text) {
if (!HLRE || !text) return esc(text);
let out = '', last = 0, m; HLRE.lastIndex = 0;
while ((m = HLRE.exec(text))) {
if (!m[0].length) { HLRE.lastIndex++; continue; }
out += esc(text.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>'; last = m.index + m[0].length;
}
return out + esc(text.slice(last));
}
function hlPos(p) {
const t = HLPOS.filter(x => p.startsWith(x)).sort((a, b) => b.length - a.length)[0];
return t ? `<mark>${esc(p.slice(0, t.length))}</mark>${esc(p.slice(t.length))}` : esc(p);
}
function markup(s) {
if (!s) return '';
return esc(s).replace(/&lt;(\/?)b&gt;/gi, '<$1b>').replace(/&lt;br\s*\/?&gt;/gi, '<br>')
.replace(/&lt;a href=&quot;(https?:\/\/[^&]+?)&quot;&gt;/gi, '<a href="$1" target="_blank" rel="noopener">')
.replace(/&lt;\/a&gt;/gi, '</a>').replace(/\n/g, '<br>');
}
// ---------------------------------------------------------------- Filtern
let RES = [], rendered = 0, CNT = {};
const inc = (o, k) => { o[k] = (o[k] || 0) + 1; };
function inRange(v, a, b) { a = pn(a); b = pn(b); return (isNaN(a) || v >= a) && (isNaN(b) || v <= b); }
function refresh(keep) {
runSearch();
const c = {kap: {}, gr: {}, me: {}, lg: {}, roh: {}, yr: {}};
const rng = S.aeMin || S.aeMax || S.epMin || S.epMax;
RES = [];
for (const r of ROWS) {
if (SC && SC[r.idx] < 0) continue;
if (S.vt && r.v !== S.vt) continue;
if (rng && !(inRange(r.$.ae, S.aeMin, S.aeMax) && inRange(vat(r.$.ep), S.epMin, S.epMax))) continue;
const fk = !S.kap || r.kap === S.kap, fg = !S.gr || r.gr === S.gr;
const fme = !S.me.size || S.me.has(r.me), flg = !S.lg.size || S.lg.has(r.lg);
const froh = !S.roh.size || r.rk.some(k => S.roh.has(k)), fyr = !S.yr.size || S.yr.has(r.yr);
const rest = fme && flg && froh && fyr;
if (rest) { inc(c.kap, r.kap); if (fk) inc(c.gr, r.gr); }
if (!(fk && fg)) continue;
if (flg && froh && fyr) inc(c.me, r.me);
if (fme && froh && fyr) inc(c.lg, r.lg);
if (fme && flg && fyr) for (const k of r.rk) inc(c.roh, k);
if (fme && flg && froh) inc(c.yr, r.yr);
if (rest) RES.push(r);
}
CNT = c;
sortRes();
renderSide();
renderTable(keep);
renderToolbar();
writeHash();
}
function sortKey() { return S.sort === 'rel' && !SC ? 'p' : (S.sort || (SC ? 'rel' : 'p')); }
function sortRes() {
const k = sortKey(), d = S.dir, byP = (a, b) => a.p < b.p ? -1 : a.p > b.p ? 1 : 0;
const num = {ae: r => r.$.ae, lohn: r => r.$.lohn, mat: r => r.$.mat, ep: r => r.$.ep}[k];
if (k === 'rel') RES.sort((a, b) => SC[b.idx] - SC[a.idx] || byP(a, b));
else if (k === 'k') RES.sort((a, b) => d * coll.compare(a.kt, b.kt) || byP(a, b));
else if (k === 'me') RES.sort((a, b) => d * coll.compare(meLbl(a.me), meLbl(b.me)) || byP(a, b));
else if (num) RES.sort((a, b) => d * (num(a) - num(b)) || byP(a, b));
else RES.sort((a, b) => d * byP(a, b));
}
// ---------------------------------------------------------------- Rendering
function renderSide() {
const total = Object.values(CNT.kap).reduce((a, b) => a + b, 0);
let h = `<button class="kap all ${S.kap ? '' : 'on'}" data-kap=""><span class="kn">Alle Kapitel</span><span class="kc">${FI.format(total)}</span></button>`;
for (const k of KAPS) {
const n = CNT.kap[k] || 0;
h += `<button class="kap ${S.kap === k ? 'on' : ''} ${n ? '' : 'zero'}" data-kap="${k}"><span class="kno">${k}</span><span class="kn">${esc(G[k])}</span><span class="kc">${n ? FI.format(n) : ''}</span></button>`;
if (S.kap === k) {
h += '<div class="grs">';
for (const g of GRS[k] || []) {
const ng = CNT.gr[g] || 0;
if (!ng && S.gr !== g) continue;
h += `<button class="gr ${S.gr === g ? 'on' : ''}" data-gr="${g}"><span class="kno">${g.slice(3)}</span><span class="kn">${esc(G[g])}</span><span class="kc">${FI.format(ng)}</span></button>`;
}
h += '</div>';
}
}
$('#chapters').innerHTML = h;
const grp = (title, key, labels, order) => {
const keys = order(Object.keys(CNT[key]).concat([...S[key]].filter(k => !(k in CNT[key]))));
let o = `<div class="fg"><div class="lbl">${title}</div><div class="chipset">`;
for (const k of keys) {
const n = CNT[key][k] || 0;
o += `<button class="chip ${S[key].has(k) ? 'on' : ''}" data-f="${key}" data-v="${esc(k)}">${esc(labels(k))}<span>${FI.format(n)}</span></button>`;
}
return o + '</div></div>';
};
const byCount = key => ks => ks.sort((a, b) => (CNT[key][b] || 0) - (CNT[key][a] || 0));
$('#facets').innerHTML =
grp('Einheit', 'me', meLbl, byCount('me')) +
grp('Lohngruppe', 'lg', k => `LG ${k} · ${F2.format(rateOf(k))} €`, ks => ks.sort()) +
grp('Rohstoff', 'roh', k => ({CU: 'Kupfer', AL: 'Aluminium', '–': 'ohne'})[k] || k, ks => ks.sort((a, b) => (a === '–') - (b === '–') || a.localeCompare(b))) +
grp('Letzte Änderung', 'yr', k => k, ks => ks.sort().reverse());
}
const CHUNK = 200;
function rowHtml(r, i) {
const p = r.$, zt = r.zt && r.zt !== r.kt ? `<div class="zt">${hl(r.zt)}</div>` : '';
return `<tr data-i="${i}"${r.p === S.sel ? ' class="sel"' : ''}><td class="pos">${hlPos(r.p)}</td>` +
`<td><div class="kt">${hl(r.kt)}</div>${zt}</td><td class="me">${esc(meLbl(r.me))}</td>` +
`<td class="num${p.ae < 0 ? ' neg' : ''}">${F2.format(p.ae)}</td><td class="num c-lohn">${money(p.lohn)}</td>` +
`<td class="num c-mat">${money(p.mat)}${p.roh ? '<i class="roh" title="inkl. Rohstoff-Mehr-/Minderpreis">●</i>' : ''}</td>` +
`<td class="num ep${p.ep < 0 ? ' neg' : ''}">${money(p.ep)}</td>` +
`<td><button class="add" data-add="${r.p}" title="Zur Merkliste" aria-label="Zur Merkliste">+</button></td></tr>`;
}
function moreRows(upto) {
const end = Math.min(RES.length, Math.max(rendered + CHUNK, upto || 0));
if (end <= rendered) return;
let h = ''; for (let i = rendered; i < end; i++) h += rowHtml(RES[i], i);
$('#tb').insertAdjacentHTML('beforeend', h); rendered = end;
}
function renderTable(keep) {
const sc = $('#scroller'), top = sc.scrollTop, had = rendered;
$('#tb').innerHTML = ''; rendered = 0;
moreRows(keep ? had : 0);
sc.scrollTop = keep ? top : 0;
$('#empty').hidden = RES.length > 0;
const k = sortKey();
document.querySelectorAll('.res th[data-s]').forEach(th => {
const on = th.dataset.s === k;
th.classList.toggle('sorted', on);
th.querySelector('.arr')?.remove();
if (on) th.insertAdjacentHTML('beforeend', `<span class="arr">${S.dir < 0 ? '▼' : '▲'}</span>`);
});
$('#epLbl').textContent = C.br ? 'brutto' : 'netto';
}
function renderToolbar() {
const sk = sortKey();
$('#count').innerHTML = `<b>${FI.format(RES.length)}</b> von ${FI.format(ROWS.length)} Positionen` + (sk === 'rel' ? ' · nach Relevanz' : '');
const ch = [];
const add = (label, rm) => ch.push(`<span class="achip"><em>${esc(label)}</em><button data-rm="${esc(rm)}" title="Entfernen">×</button></span>`);
if (S.q) add(`„${S.q}“`, 'q');
if (S.kap) add(`${S.kap} ${G[S.kap] || ''}`, 'kap');
if (S.gr) add(`${S.gr} ${G[S.gr] || ''}`, 'gr');
for (const k of S.me) add(`Einheit ${meLbl(k)}`, 'me:' + k);
for (const k of S.lg) add(`LG ${k}`, 'lg:' + k);
for (const k of S.roh) add(({CU: 'Kupfer', AL: 'Aluminium', '–': 'ohne Rohstoff'})[k] || k, 'roh:' + k);
for (const k of S.yr) add(`Stand ${k}`, 'yr:' + k);
if (S.aeMin || S.aeMax) add(`Zeit ${S.aeMin || '…'}–${S.aeMax || '…'} min`, 'ae');
if (S.epMin || S.epMax) add(`EP ${S.epMin || '…'}–${S.epMax || '…'} €`, 'ep');
if (S.vt) add(`Vortext ${S.vt}`, 'vt');
$('#chips').innerHTML = ch.join(' ');
$('#reset').hidden = !ch.length;
}
// ---------------------------------------------------------------- Detail
let detailOpen = false;
function selIndex() { return S.sel ? RES.findIndex(r => r.p === S.sel) : -1; }
function markSel(scroll) {
document.querySelectorAll('#tb tr.sel').forEach(t => t.classList.remove('sel'));
const i = selIndex(); if (i < 0) return;
if (i >= rendered) moreRows(i + 1);
const tr = $(`#tb tr[data-i="${i}"]`);
if (tr) { tr.classList.add('sel'); if (scroll) tr.scrollIntoView({block: 'nearest'}); }
}
function select(p, open) {
S.sel = p; markSel(true);
if (open || detailOpen) openDetail(p); else writeHash();
}
function openDetail(p) {
const r = BYPOS[p]; if (!r) return;
S.sel = p; detailOpen = true; markSel(true);
document.body.classList.add('has-detail');
$('#detail').hidden = false;
renderDetail(r);
writeHash();
}
function closeDetail() {
detailOpen = false; $('#detail').hidden = true; document.body.classList.remove('has-detail');
S.sel = ''; markSel(); writeHash();
}
function projTotal(pr) {
let t = 0;
for (const it of pr.items) if (it.p && BYPOS[it.p]) t += r2(price(BYPOS[it.p], it).ep * it.n);
return t;
}
function renderDetail(r) {
const p = r.$, me = meLbl(r.me), mw = P.mwst;
const cmp = ['1', '2'].map(s => P.zuschlag.map(z => ({s, z, ep: price(r, {lohn: P.lohn[s][r.lg], zu: z}).ep})));
const isCur = (s, z) => P.lohn[s][r.lg] === rateOf(r.lg) && z === C.zu;
const roh = r.r.map(([art, gew, basis, korr]) => {
const del = art === 'CU' ? C.cu : art === 'AL' ? C.al : basis;
const z = (del * (C.bez ? 1.01 : 1) - basis) / 100 * (gew + (C.korr ? korr : 0)) / 1000;
return `<tr><td>${({CU: 'Kupfer', AL: 'Aluminium'})[art] || art}</td><td class="num">${FN.format(gew)}</td><td class="num">${FN.format(basis)}</td><td class="num">${FN.format(korr)}</td><td class="num">${F2.format(z)}</td></tr>`;
}).join('');
const pr = PROJ[r.p];
let projHtml = '';
if (pr) {
projHtml = `<h3>Muster-LV · ${esc(pr.file)}</h3><table class="mini"><tr><th>Pos.</th><th>Kurztext</th><th class="num">Menge</th><th class="num">GP</th></tr>` +
pr.items.map(it => {
if (it.t) return `<tr class="ttl"><td colspan="4">${esc(it.t)}</td></tr>`;
const x = BYPOS[it.p];
return `<tr><td><a data-goto="${it.p}">${it.p}</a></td><td>${esc(x ? x.kt : '?')}</td><td class="num">${FN.format(it.n)} ${x ? esc(meLbl(x.me)) : ''}</td><td class="num">${x ? F2.format(r2(price(x, it).ep * it.n)) : ''}</td></tr>`;
}).join('') +
`<tr class="ttl"><td colspan="3">Summe netto</td><td class="num">${F2.format(projTotal(pr))}</td></tr></table>` +
`<p><button class="btn" data-loadproj="${esc(pr.nr)}">In Merkliste übernehmen</button></p>`;
}
const i = selIndex();
$('#detail').innerHTML = `
<div class="dh">
<button class="icon-btn" data-nav="-1" title="Vorherige Position (↑)" ${i <= 0 ? 'disabled' : ''}>‹</button>
<button class="icon-btn" data-nav="1" title="Nächste Position (↓)" ${i < 0 || i >= RES.length - 1 ? 'disabled' : ''}>›</button>
<div class="dpos">${esc(r.p)}${i >= 0 ? `<small>${FI.format(i + 1)} / ${FI.format(RES.length)}</small>` : ''}</div>
<button class="icon-btn" data-close title="Schließen (Esc)">✕</button>
</div>
<div class="dbody">
<div class="crumb"><a data-kap="${r.kap}">${r.kap} ${esc(G[r.kap] || '')}</a> › <a data-kap="${r.kap}" data-gr="${r.gr}">${r.gr} ${esc(G[r.gr] || '')}</a></div>
<h2>${markup(r.k) || esc(r.i)}</h2>
<div class="lang">${r.v ? `<div>${markup(VT[r.v])}</div>` : ''}${r.z ? `<div class="zus">${markup(r.z)}</div>` : ''}</div>
${r.v ? `<button class="link vtlink" data-vt="${esc(r.v)}">Alle ${FI.format(VTCOUNT[r.v])} Positionen mit Vortext ${esc(r.v)} anzeigen →</button>` : ''}
<h3>Kalkulation je ${esc(me)}</h3>
<table class="calc">
<tr><td>Bauzeit${C.bz !== 1 ? ` <small>(Faktor ${FN.format(C.bz)})</small>` : ''}</td><td>${F2.format(p.ae)} min</td></tr>
<tr><td>Lohn <small>${F2.format(p.rate)} €/h · LG ${r.lg}</small></td><td>${F2.format(p.lohn)} €</td></tr>
<tr class="sub"><td>Material Einkauf netto <small>(Cu ${FN.format(P.basis.CU)} / Al ${FN.format(P.basis.AL)})</small></td><td>${F2.format(r.mat)} €</td></tr>
${r.r.length ? `<tr class="sub"><td>Rohstoff-Mehr-/Minderpreis</td><td>${F2.format(p.roh)} €</td></tr>` : ''}
<tr class="sub"><td>Materialzuschlag ${FN.format(p.zu)} %</td><td>${F2.format(p.mat - p.matEK)} €</td></tr>
<tr><td>Material</td><td>${F2.format(p.mat)} €</td></tr>
<tr class="sum"><td>Einheitspreis netto</td><td>${F2.format(p.ep)} €</td></tr>
<tr class="sub"><td>MwSt ${FN.format(mw)} %</td><td>${F2.format(p.ep * mw / 100)} €</td></tr>
<tr class="br"><td>Einheitspreis brutto</td><td>${F2.format(p.ep * (1 + mw / 100))} €</td></tr>
</table>
<h3>Kalkulationssätze der KFE (netto)</h3>
<table class="cmp">
<tr><th></th>${P.zuschlag.map(z => `<th>Material +${FN.format(z)} %</th>`).join('')}</tr>
${cmp.map(row => `<tr><th>${F2.format(P.lohn[row[0].s][r.lg])} €/h</th>${row.map(c => `<td class="${isCur(c.s, c.z) ? 'on' : ''}">${F2.format(c.ep)}</td>`).join('')}</tr>`).join('')}
</table>
${r.r.length ? `<h3>Rohstoff</h3><table class="mini"><tr><th>Art</th><th class="num">Cu-Zahl kg/km</th><th class="num">Basis €/100 kg</th><th class="num">Korrektur</th><th class="num">Zuschlag €</th></tr>${roh}</table>` : ''}
<h3>Daten</h3>
<table class="kv">
<tr><td>Matchcode</td><td>${esc(r.i)}</td></tr>
<tr><td>Einheit</td><td>${esc(me)} <small>(Code ${esc(r.me)})</small></td></tr>
<tr><td>Lohngruppe</td><td>${esc(r.lg)}</td></tr>
<tr><td>Alternative Nr.</td><td>${esc(r.a || '–')}</td></tr>
<tr><td>Vortext-ID</td><td>${esc(r.v || '–')}</td></tr>
<tr><td>Letzte Änderung</td><td>${fmtDate(r.d)}</td></tr>
</table>
${projHtml}
</div>
<div class="dfoot">
<input type="text" inputmode="decimal" id="dQty" value="1" aria-label="Menge"> <span>${esc(me)}</span>
<button class="btn primary" data-addqty="${esc(r.p)}">Zur Merkliste</button>
<span class="spacer"></span>
<button class="btn" data-copy="${esc(r.p)}" title="Kurz- und Langtext kopieren">Text kopieren</button>
</div>`;
}
// ---------------------------------------------------------------- Merkliste
let ML = store.get('kfe.ml', []), mlOpen = false;
function saveML() { store.set('kfe.ml', ML); $('#mlCount').textContent = ML.filter(x => x.p).length; }
function addML(p, n) {
n = isNaN(n) ? 1 : n;
const ex = ML.find(x => x.p === p && x.lohn == null && x.zu == null);
if (ex) ex.n = r2(ex.n + n); else ML.push({p, n});
saveML(); toast(`${p} zur Merkliste hinzugefügt`); if (mlOpen) renderML();
}
function mlLines() {
return ML.map((it, i) => {
if (it.t != null) return {i, it};
const r = BYPOS[it.p], pr = r ? price(r, it) : null;
return {i, it, r, pr, gp: pr ? r2(pr.ep * it.n) : 0};
});
}
function mlSums(lines) {
const s = {lohn: 0, mat: 0, ep: 0, min: 0};
for (const l of lines) if (l.pr) { s.lohn += r2(l.pr.lohn * l.it.n); s.mat += r2(l.pr.mat * l.it.n); s.ep += l.gp; s.min += l.pr.ae * l.it.n; }
return s;
}
function renderML() {
const lines = mlLines(), s = mlSums(lines);
const opts = D.projects.map(pr => `<option value="${esc(pr.nr)}">${esc(pr.nr)} · ${esc(pr.name)}</option>`).join('');
const body = lines.length ? `<table class="ml"><thead><tr><th>Pos.</th><th>Kurztext</th><th class="num">Menge</th><th>ME</th><th class="num">EP ${C.br ? 'brutto' : 'netto'}</th><th class="num">GP</th><th></th></tr></thead><tbody>` +
lines.map(l => l.it.t != null
? `<tr class="ttl"><td colspan="6">${esc(l.it.t)}</td><td><button class="del" data-del="${l.i}" title="Entfernen">×</button></td></tr>`
: `<tr><td class="p" data-goto="${esc(l.it.p)}">${esc(l.it.p)}</td><td>${l.r ? esc(l.r.kt) : '<i>unbekannte Position</i>'}${l.it.lohn != null || l.it.zu != null ? ` <small>(${l.it.lohn != null ? F2.format(l.it.lohn) + ' €/h' : ''}${l.it.zu != null ? ' +' + FN.format(l.it.zu) + ' %' : ''})</small>` : ''}</td>` +
`<td class="num"><input type="text" inputmode="decimal" data-qty="${l.i}" value="${FN.format(l.it.n)}"></td><td>${l.r ? esc(meLbl(l.r.me)) : ''}</td>` +
`<td class="num">${l.pr ? money(l.pr.ep) : ''}</td><td class="num"><b>${money(l.gp)}</b></td><td><button class="del" data-del="${l.i}" title="Entfernen">×</button></td></tr>`
).join('') + '</tbody></table>'
: `<div class="mlempty">Die Merkliste ist leer.<br>Positionen mit <b>+</b> in der Tabelle hinzufügen oder ein Muster-LV laden.</div>`;
$('#ml').innerHTML = `
<div class="mlh"><h2>Merkliste <small>${ML.filter(x => x.p).length} Positionen</small></h2>
<select id="mlProj"><option value="">Muster-LV hinzufügen …</option>${opts}</select>
<button class="btn" id="mlCsv" ${lines.length ? '' : 'disabled'}>CSV-Export</button>
<button class="btn" id="mlClear" ${lines.length ? '' : 'disabled'}>Leeren</button>
<button class="icon-btn" id="mlClose" title="Schließen (Esc)">✕</button></div>
<div class="mlbody">${body}</div>
<div class="mlf">
<div>Bauzeit gesamt<b>${F2.format(s.min / 60)} h</b></div>
<div>Lohn netto<b>${F2.format(s.lohn)} €</b></div>
<div>Material netto<b>${F2.format(s.mat)} €</b></div>
<div>Summe netto<b>${F2.format(s.ep)} €</b></div>
<div>MwSt ${FN.format(P.mwst)} %<b>${F2.format(s.ep * P.mwst / 100)} €</b></div>
<div>Summe brutto<b>${F2.format(s.ep * (1 + P.mwst / 100))} €</b></div>
</div>`;
}
function openML() { mlOpen = true; renderML(); $('#ml').hidden = false; $('#mlBack').hidden = false; }
function closeML() { mlOpen = false; $('#ml').hidden = true; $('#mlBack').hidden = true; }
function loadProject(nr) {
const pr = PROJ[nr]; if (!pr) return;
ML.push({t: `${pr.nr} · ${pr.name}`});
for (const it of pr.items) ML.push(it.t ? {t: it.t} : {...it});
saveML(); toast(`Muster-LV ${pr.nr} übernommen`); if (mlOpen) renderML();
}
function exportCSV() {
const lines = mlLines(), s = mlSums(lines), f = x => F2.format(x);
const q = v => /[;"\n]/.test(v) ? '"' + String(v).replace(/"/g, '""') + '"' : v;
const out = [['Pos', 'Kurztext', 'Menge', 'ME', 'Bauzeit/ME min', 'Lohn/ME', 'Material/ME', 'EP netto', 'GP netto']];
for (const l of lines) {
if (l.it.t != null) { out.push(['', l.it.t, '', '', '', '', '', '', '']); continue; }
out.push([l.it.p, l.r ? l.r.kt : '?', FN.format(l.it.n), l.r ? meLbl(l.r.me) : '', l.pr ? f(l.pr.ae) : '',
l.pr ? f(l.pr.lohn) : '', l.pr ? f(l.pr.mat) : '', l.pr ? f(l.pr.ep) : '', f(l.gp)]);
}
out.push([], ['', 'Summe netto', '', '', f(s.min), f(s.lohn), f(s.mat), '', f(s.ep)],
['', `MwSt ${FN.format(P.mwst)} %`, '', '', '', '', '', '', f(s.ep * P.mwst / 100)],
['', 'Summe brutto', '', '', '', '', '', '', f(s.ep * (1 + P.mwst / 100))]);
const blob = new Blob(['\ufeff' + out.map(r => r.map(q).join(';')).join('\r\n')], {type: 'text/csv;charset=utf-8'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'kfe-merkliste.csv';
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
let toastT;
function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 1800); }
// ---------------------------------------------------------------- Kalkulations-Panel
const CIN = {l10: 'c_l10', l11: 'c_l11', l12: 'c_l12', zu: 'c_zu', cu: 'c_cu', al: 'c_al', bz: 'c_bz'};
$('#segZu').innerHTML = P.zuschlag.map(z => `<button data-v="${z}">${FN.format(z)} %</button>`).join('');
function syncCalcUI(skip) {
for (const k in CIN) if (CIN[k] !== skip) $('#' + CIN[k]).value = FN.format(C[k]);
$('#c_bez').checked = C.bez; $('#c_korr').checked = C.korr; $('#c_br').value = C.br ? '1' : '0';
for (const b of $('#segLohn').children) { const L = P.lohn[b.dataset.v]; b.classList.toggle('on', L['10'] === C.l10 && L['11'] === C.l11 && L['12'] === C.l12); }
for (const b of $('#segZu').children) b.classList.toggle('on', +b.dataset.v === C.zu);
}
function calcChanged(skip) {
store.set('kfe.calc', C); syncCalcUI(skip); recalc(); refresh(true);
if (detailOpen && BYPOS[S.sel]) renderDetail(BYPOS[S.sel]);
if (mlOpen) renderML();
}
for (const k in CIN) $('#' + CIN[k]).addEventListener('input', debounce(e => {
const v = pn(e.target.value); if (isNaN(v)) return; C[k] = v; calcChanged(CIN[k]);
}, 250));
$('#c_bez').onchange = e => { C.bez = e.target.checked; calcChanged(); };
$('#c_korr').onchange = e => { C.korr = e.target.checked; calcChanged(); };
$('#c_br').onchange = e => { C.br = e.target.value === '1'; calcChanged(); };
$('#segLohn').onclick = e => { const v = e.target.dataset.v; if (!v) return; Object.assign(C, {l10: P.lohn[v]['10'], l11: P.lohn[v]['11'], l12: P.lohn[v]['12']}); calcChanged(); };
$('#segZu').onclick = e => { const v = e.target.closest('button')?.dataset.v; if (!v) return; C.zu = +v; calcChanged(); };
$('#c_reset').onclick = () => { C = DEF(); calcChanged(); };
// ---------------------------------------------------------------- Events
const qIn = $('#q');
qIn.addEventListener('input', debounce(() => { S.q = qIn.value.trim(); S.sort = ''; S.dir = 1; refresh(); }, 110));
$('#lt').onchange = e => { S.lt = e.target.checked; refresh(); };
for (const k of ['aeMin', 'aeMax', 'epMin', 'epMax'])
$('#f_' + k).addEventListener('input', debounce(e => { S[k] = e.target.value.trim(); refresh(); }, 250));
$('#chapters').onclick = e => {
const b = e.target.closest('button'); if (!b) return;
if (b.dataset.gr != null) S.gr = S.gr === b.dataset.gr ? '' : b.dataset.gr;
else { const k = b.dataset.kap; S.kap = S.kap === k ? '' : k; S.gr = ''; }
refresh();
};
$('#facets').onclick = e => {
const b = e.target.closest('.chip'); if (!b) return;
const set = S[b.dataset.f], v = b.dataset.v;
set.has(v) ? set.delete(v) : set.add(v); refresh();
};
function resetAll() {
S.q = ''; qIn.value = ''; S.kap = S.gr = S.vt = ''; for (const k of SETS) S[k].clear();
for (const k of ['aeMin', 'aeMax', 'epMin', 'epMax']) { S[k] = ''; $('#f_' + k).value = ''; }
S.sort = ''; S.dir = 1; refresh();
}
$('#reset').onclick = resetAll; $('#reset2').onclick = resetAll;
$('#chips').onclick = e => {
const rm = e.target.closest('[data-rm]')?.dataset.rm; if (!rm) return;
const [k, v] = rm.split(':');
if (k === 'q') { S.q = ''; qIn.value = ''; }
else if (k === 'kap') { S.kap = ''; S.gr = ''; }
else if (k === 'gr') S.gr = '';
else if (k === 'vt') S.vt = '';
else if (k === 'ae' || k === 'ep') { for (const x of ['Min', 'Max']) { S[k + x] = ''; $('#f_' + k + x).value = ''; } }
else S[k].delete(v);
refresh();
};
document.querySelector('.res thead').onclick = e => {
const th = e.target.closest('th[data-s]'); if (!th) return;
const k = th.dataset.s;
if (sortKey() === k) { S.sort = k; S.dir = -S.dir; } else { S.sort = k; S.dir = 1; }
sortRes(); renderTable(); renderToolbar(); writeHash(); markSel(false);
};
$('#tb').onclick = e => {
const add = e.target.closest('[data-add]');
if (add) { addML(add.dataset.add, 1); return; }
const tr = e.target.closest('tr[data-i]'); if (!tr) return;
const r = RES[+tr.dataset.i];
if (detailOpen && S.sel === r.p) closeDetail(); else openDetail(r.p);
};
new IntersectionObserver(es => { if (es[0].isIntersecting) moreRows(); }, {root: $('#scroller'), rootMargin: '600px'}).observe($('#sentinel'));
$('#detail').onclick = e => {
const t = e.target.closest('[data-close],[data-nav],[data-kap],[data-vt],[data-goto],[data-addqty],[data-copy],[data-loadproj]');
if (!t) return;
const d = t.dataset;
if (d.close != null) closeDetail();
else if (d.nav) move(+d.nav);
else if (d.goto) openDetail(d.goto);
else if (d.addqty) addML(d.addqty, pn($('#dQty').value));
else if (d.loadproj) loadProject(d.loadproj);
else if (d.copy) {
const r = BYPOS[d.copy];
const txt = `${r.p} ${stripTags(r.k)}\n\n${r.v ? stripTags(VT[r.v]) + '\n' : ''}${stripTags(r.z)}`.trim();
navigator.clipboard?.writeText(txt).then(() => toast('Text kopiert'), () => toast('Kopieren nicht möglich'));
}
else if (d.vt) { S.vt = d.vt; S.kap = S.gr = ''; refresh(); markSel(true); }
else if (d.kap) { S.kap = d.kap; S.gr = d.gr || ''; refresh(); markSel(true); }
};
$('#detail').addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.id === 'dQty') { addML(S.sel, pn(e.target.value)); } });
$('#btnML').onclick = openML;
$('#mlBack').onclick = closeML;
$('#ml').onclick = e => {
const t = e.target;
if (t.id === 'mlClose') closeML();
else if (t.id === 'mlCsv') exportCSV();
else if (t.id === 'mlClear') { if (confirm('Merkliste wirklich leeren?')) { ML = []; saveML(); renderML(); } }
else if (t.dataset.del != null) { ML.splice(+t.dataset.del, 1); saveML(); renderML(); }
else if (t.dataset.goto) { closeML(); openDetail(t.dataset.goto); }
};
$('#ml').onchange = e => {
const t = e.target;
if (t.id === 'mlProj' && t.value) loadProject(t.value);
else if (t.dataset.qty != null) { const v = pn(t.value); if (!isNaN(v)) { ML[+t.dataset.qty].n = v; saveML(); } renderML(); }
};
$('#btnSide').onclick = () => document.body.classList.toggle('side-open');
document.addEventListener('click', e => {
if (document.body.classList.contains('side-open') && !e.target.closest('aside,#btnSide')) document.body.classList.remove('side-open');
});
function move(d) {
if (!RES.length) return;
let i = selIndex();
i = i < 0 ? (d > 0 ? 0 : RES.length - 1) : Math.max(0, Math.min(RES.length - 1, i + d));
select(RES[i].p);
}
document.addEventListener('keydown', e => {
const tag = e.target.tagName, typing = (tag === 'INPUT' && e.target.type !== 'checkbox') || tag === 'SELECT' || tag === 'TEXTAREA';
if (e.key === 'Escape') {
if (mlOpen) closeML();
else if (document.body.classList.contains('side-open')) document.body.classList.remove('side-open');
else if (detailOpen) closeDetail();
else if (e.target === qIn && qIn.value) { qIn.value = ''; S.q = ''; refresh(); }
else if (typing) e.target.blur();
return;
}
if (mlOpen) return;
if (e.key === '/' && !typing) { e.preventDefault(); qIn.focus(); qIn.select(); return; }
if (typing && e.target !== qIn) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); move(e.key === 'ArrowDown' ? 1 : -1); }
else if (e.key === 'Enter' && S.sel) { e.preventDefault(); openDetail(S.sel); }
else if (e.key === '+' && !typing && S.sel) addML(S.sel, 1);
});
// ---------------------------------------------------------------- Start
function fromHash() {
readHash();
qIn.value = S.q; $('#lt').checked = S.lt;
for (const k of ['aeMin', 'aeMax', 'epMin', 'epMax']) $('#f_' + k).value = S[k];
refresh();
if (S.sel && BYPOS[S.sel]) openDetail(S.sel); else if (detailOpen) closeDetail();
}
// replaceState löst kein hashchange aus – das Event kommt nur bei manueller URL-Änderung / Zurück
window.addEventListener('hashchange', fromHash);
syncCalcUI(); recalc(); saveML(); fromHash();
$('#loading').remove();
})();
</script>
</body>
</html>