kfe-viewer-py/index.html
Julian Appel a19558fc6c 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>
2026-10-03 14:00:55 +02:00

955 lines
53 KiB
HTML
Raw Permalink 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>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>