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:
commit
a19558fc6c
10 changed files with 1404 additions and 0 deletions
955
index.html
Normal file
955
index.html
Normal 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 & 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 LG 11: u. a. Kap. 04.80–04.83 und 30 (Bustechnik) LG 12: Kap. 23–28 (IT-Installation, Drucker/Kopierer) 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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(/<(\/?)b>/gi, '<$1b>').replace(/<br\s*\/?>/gi, '<br>')
|
||||
.replace(/<a href="(https?:\/\/[^&]+?)">/gi, '<a href="$1" target="_blank" rel="noopener">')
|
||||
.replace(/<\/a>/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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue