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

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

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

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

3
.dockerignore Normal file
View file

@ -0,0 +1,3 @@
*
!server.py
!index.html

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
# Kopieren nach .env und Pfad anpassen
# Pfad zum KFE-Datenverzeichnis (enthält KFE.DBF), relativ zu compose.yaml
KFE_DATA_DIR=./data

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
*.bat text eol=crlf

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# KFE-Daten sind lizenzpflichtig und gehören nicht ins Repo
data/
.env
__pycache__/
*.pyc

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
# KFE-Viewer als Container. Die KFE-Daten (lizenzpflichtig) sind nicht im Image enthalten,
# sondern werden zur Laufzeit nach /data gemountet – siehe compose.yaml bzw. README.
FROM python:3.13-alpine
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
KFE_DATA=/data \
KFE_HOST=0.0.0.0 \
KFE_PORT=8765 \
KFE_NO_BROWSER=1
WORKDIR /app
COPY server.py index.html ./
RUN adduser -D -H -s /sbin/nologin kfe
USER kfe
EXPOSE 8765
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/' % os.environ['KFE_PORT'], timeout=2)"
CMD ["python", "server.py"]

76
README.md Normal file
View file

@ -0,0 +1,76 @@
# KFE Viewer
Leichtgewichtiger Web-Viewer für die ZVEH-Kalkulationshilfe (KFE). Liest die
dBase/FoxPro-Dateien des originalen KFE-Viewers direkt ein – keine Konvertierung,
keine Abhängigkeiten außer Python ≥ 3.8.
## Daten
Die KFE-Daten sind **nicht** in diesem Repository enthalten. Sie sind lizenzpflichtig
(Herausgeber ZVEH, Vertrieb META Handelsgesellschaft). Benötigt wird das Datenverzeichnis des
originalen KFE-Viewers, auf der KFE-CD unter `0_software/KFE_ViewerCD3/Daten`. Darin liegen
u. a. `KFE.DBF`/`.FPT`, `VORTEXT.DBF`/`.FPT`, `GRUPPEN.DBF`, `STDPARAM.DAT`, `LOHNSTD.DBF` und `Projekt/*.prj`.
Am einfachsten kopierst du den Inhalt nach `./data` (per `.gitignore` ausgeschlossen) oder gibst den Pfad mit `--data` an.
## Start
```bash
python server.py
```
Öffnet automatisch http://127.0.0.1:8765. Unter Windows geht auch ein Doppelklick auf `start.bat`.
| Option | Bedeutung |
|---|---|
| `--data PFAD` | Verzeichnis mit `KFE.DBF`. Standard: `./data`, alternativ `../2020-05-12-NEU/0_software/KFE_ViewerCD3/Daten` |
| `--port 8765` | Port |
| `--host 0.0.0.0` | im LAN erreichbar machen |
| `--no-browser` | Browser nicht automatisch öffnen |
| `--static kfe.html` | eine einzelne HTML-Datei mit eingebetteten Daten erzeugen (ohne Server nutzbar, ca. 5 MB) |
Alle Optionen lassen sich auch per Umgebungsvariable setzen: `KFE_DATA`, `KFE_HOST`, `KFE_PORT`, `KFE_NO_BROWSER=1`.
## Docker
Das Image enthält nur die App (ca. 80 MB, Basis `python:3.13-alpine`, läuft als Nicht-root-Benutzer).
Die Daten werden schreibgeschützt nach `/data` gemountet.
```bash
cp .env.example .env # KFE_DATA_DIR auf das Datenverzeichnis setzen (Standard: ./data)
docker compose up -d --build
```
Danach erreichbar unter http://127.0.0.1:8765. Weitere Befehle: `docker compose logs`, `docker compose down`.
Für Zugriff aus dem LAN in `compose.yaml` den Port auf `"8765:8765"` ändern.
Ohne Compose:
```bash
docker build -t kfe-viewer .
docker run -d -p 127.0.0.1:8765:8765 -v /pfad/zu/Daten:/data:ro --name kfe-viewer kfe-viewer
```
## Funktionen
- **Live-Suche** über Positionsnummer, Matchcode, Kurztext, Zusatztext und optional den Langtext (Vortext)
- mehrere Begriffe = UND, `-wort` schließt aus, `"genaue phrase"`
- `04.03` sucht nach Positionsnummer-Präfix
- Umlaute/Komma egal: `schutzkontakt` findet „Schutzkontakt“, `3x1.5` findet „3x1,5“
- **Filter** mit Trefferzahlen: Kapitel → Gruppe, Einheit, Lohngruppe, Rohstoff (Cu/Al), Änderungsjahr, Bauzeit- und Preisbereich
- **Kalkulation live**: Stundensätze je Lohngruppe (Satz 1/2 oder eigene), Materialzuschlag, Bauzeitfaktor,
aktuelle DEL-Notiz für Kupfer/Aluminium (Rohstoff-Mehr-/Minderpreis), netto/brutto
- **Detailansicht** mit Langtext, Kalkulationsaufschlüsselung, den vier KFE-Kalkulationssätzen, Rohstoffdaten
- **Merkliste** mit Mengen, Summen (Lohn/Material/netto/brutto/Bauzeit) und CSV-Export; die 60 Muster-LVs
(`Daten/Projekt/*.prj`) lassen sich direkt übernehmen
- URL enthält Suche und Filter (Lesezeichen/teilen), Tastatur: `/` Suche, `↑`/`↓` Auswahl, `Enter` Details, `+` merken, `Esc` schließen
- Hell/Dunkel-Modus, mobil nutzbar
## Preisformel
```
EP = Bauzeit[min] × Bauzeitfaktor × Stundensatz(LG) / 60 + (Material + Rohstoffzuschlag) × (1 + Zuschlag %)
Rohstoffzuschlag = (DEL − Basis) / 100 × Cu-Zahl[kg/km] / 1000
```
Mit den Standardwerten (43,75 €/h, 20 %, Cu 550) stimmen die Preise mit dem GAEB-Export der KFE überein.

12
compose.yaml Normal file
View file

@ -0,0 +1,12 @@
services:
kfe-viewer:
build: .
image: kfe-viewer:latest
container_name: kfe-viewer
ports:
# nur lokal erreichbar; für Zugriff aus dem LAN "8765:8765" verwenden
- "127.0.0.1:8765:8765"
volumes:
# Verzeichnis mit KFE.DBF; Pfad über KFE_DATA_DIR (z. B. in .env) anpassbar
- ${KFE_DATA_DIR:-./data}:/data:ro
restart: unless-stopped

955
index.html Normal file
View file

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

322
server.py Normal file
View file

@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""KFE-Viewer – leichtgewichtiger Web-Viewer für die ZVEH-Kalkulationshilfe (KFE).
Liest die dBase/FoxPro-Dateien des originalen KFE-Viewers (KFE.DBF, VORTEXT.DBF, ...)
direkt ein und stellt sie über einen kleinen HTTP-Server als Web-App bereit.
Keine Abhängigkeiten außer der Python-Standardbibliothek (>= 3.8).
python server.py # startet auf http://127.0.0.1:8765
python server.py --port 9000 --no-browser
python server.py --data PFAD/ZU/Daten # anderes Datenverzeichnis
python server.py --static kfe.html # erzeugt eine einzelne HTML-Datei ohne Server
"""
import argparse
import configparser
import gzip
import hashlib
import json
import os
import re
import signal
import struct
import sys
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
HERE = Path(__file__).resolve().parent
# Standard: ./data neben dem Skript, sonst die Verzeichnisstruktur der KFE-CD daneben
DEFAULT_DATA = next((p for p in (HERE / "data",
HERE.parent / "2020-05-12-NEU" / "0_software" / "KFE_ViewerCD3" / "Daten")
if (p / "KFE.DBF").exists()), HERE / "data")
# Mengeneinheiten-Codes der ZVEHNORM (abgeglichen mit den DATANORM-Exporten)
MEH = {"01": "Stk", "12": "cm", "13": "m", "23": "kg", "33": "l", "42": "cm²", "43": "m²", "53": "Std"}
# --------------------------------------------------------------------------- DBF
def read_dbf(path, encoding="cp850"):
"""Liest eine dBase/FoxPro-Tabelle. Memofelder werden als Blocknummer (int) geliefert."""
with open(path, "rb") as f:
head = f.read(32)
count, header_len, record_len = struct.unpack("<IHH", head[4:12])
fields = []
while True:
fd = f.read(32)
if not fd or fd[0] == 0x0D:
break
name = fd[:11].split(b"\0")[0].decode("ascii")
fields.append((name, chr(fd[11]), fd[16]))
f.seek(header_len)
data = f.read(count * record_len)
rows = []
for i in range(count):
rec = data[i * record_len:(i + 1) * record_len]
if not rec or rec[0:1] == b"*":
continue
pos, row = 1, {}
for name, typ, length in fields:
raw = rec[pos:pos + length]
pos += length
if typ == "M":
if length == 4:
row[name] = struct.unpack("<I", raw)[0]
else:
s = raw.strip(b" \0")
row[name] = int(s) if s.isdigit() else 0
elif typ == "L":
row[name] = raw in (b"T", b"t", b"Y", b"y")
else:
row[name] = raw.decode(encoding, "replace").strip()
rows.append(row)
return rows
class Memo:
"""Zugriff auf eine FoxPro-Memodatei (.FPT)."""
def __init__(self, path, encoding="cp850"):
self.data = Path(path).read_bytes()
self.block = struct.unpack(">H", self.data[6:8])[0] or 64
self.encoding = encoding
def get(self, block):
if not block:
return ""
off = block * self.block
_typ, length = struct.unpack(">II", self.data[off:off + 8])
raw = self.data[off + 8:off + 8 + length]
text = raw.decode(self.encoding, "replace").rstrip("\0\x1a ")
return text.replace("\r\n", "\n").replace("\r", "\n")
def num(s, default=0.0):
try:
return float(str(s).replace(",", "."))
except (TypeError, ValueError):
return default
def iso(d):
d = (d or "").strip()
return f"{d[:4]}-{d[4:6]}-{d[6:8]}" if len(d) >= 8 and d[:8].isdigit() else ""
# --------------------------------------------------------------------------- Laden
def load_params(data_dir):
p = {
"lohn": {"1": {"10": 43.75, "11": 90.77, "12": 67.22}, "2": {"10": 49.95, "11": 114.72, "12": 87.32}},
"zuschlag": [20.0, 35.0],
"mwst": 19.0,
"bzfakt": 1.0,
"basis": {"CU": 550.0, "AL": 200.0},
"stand": "",
"zeitraum": "",
}
std = data_dir / "STDPARAM.DAT"
if std.exists():
cp = configparser.ConfigParser()
cp.read_string(std.read_text("cp1252", "replace"))
s = cp["PARAMETER"] if cp.has_section("PARAMETER") else {}
p["lohn"]["1"]["10"] = num(s.get("LOHN1"), 43.75)
p["lohn"]["2"]["10"] = num(s.get("LOHN2"), 49.95)
p["zuschlag"] = [num(s.get("ZU1"), 20), num(s.get("ZU2"), 35)]
p["mwst"] = num(s.get("MWST"), 19)
p["bzfakt"] = num(s.get("BZFAKT"), 1)
lstd = data_dir / "LOHNSTD.DBF"
if lstd.exists():
rows = read_dbf(lstd)
if rows:
r = max(rows, key=lambda r: r["ZEIT"]) # aktuellster Zeitraum, z.B. "1920"
p["zeitraum"] = f"20{r['ZEIT'][:2]}/{r['ZEIT'][2:]}"
p["lohn"]["1"]["11"] = num(r["S1_PG11"])
p["lohn"]["2"]["11"] = num(r["S2_PG11"])
p["lohn"]["1"]["12"] = num(r["S1_PG12"])
p["lohn"]["2"]["12"] = num(r["S2_PG12"])
roh = data_dir / "ROHSTOFF.DBF"
if roh.exists():
for r in read_dbf(roh):
p["basis"][r["ROHART"]] = num(r["ROHBASIS"])
ver = data_dir / "DVERSION.DAT"
if ver.exists():
m = re.search(r"UPDATE=(\d\d)/(\d\d)/(\d\d)", ver.read_text("cp1252", "replace"))
if m:
p["stand"] = f"20{m.group(1)}-{m.group(2)}-{m.group(3)}"
return p
def load_projects(proj_dir):
projects = []
if not proj_dir.is_dir():
return projects
for f in sorted(proj_dir.glob("*.prj")):
lines = f.read_text("cp1252", "replace").splitlines()
items = []
for line in lines[1:]:
c = line.split(";")
if not c or not c[0] or c[0] == "V":
continue
if c[0] == "T":
text = c[1].strip() if len(c) > 1 else ""
if text:
items.append({"t": text})
continue
if not re.match(r"^\d\d\.\d\d\.\d\d", c[0]) or len(c) < 5:
continue
item = {"p": c[0].strip(), "n": num(c[4], 1)}
if num(c[1]) > 0:
item["lohn"] = num(c[1])
if num(c[2]) > 0:
item["zu"] = num(c[2])
items.append(item)
m = re.match(r"^(\d\d)(\d\d)(\d\d)_(.*)$", f.stem)
nr = f"{m.group(1)}.{m.group(2)}.{m.group(3)}" if m else ""
name = (m.group(4) if m else f.stem).replace("_", " ")
projects.append({"file": f.name, "nr": nr, "name": name, "items": items})
return projects
def load(data_dir):
t0 = time.time()
data_dir = Path(data_dir)
if not (data_dir / "KFE.DBF").exists():
sys.exit(f"KFE.DBF nicht gefunden in: {data_dir}\nBitte mit --data das Daten-Verzeichnis angeben.")
groups = {r["GR"]: r["BEZ"] for r in read_dbf(data_dir / "GRUPPEN.DBF")}
vt_memo = Memo(data_dir / "VORTEXT.FPT")
vortexte = {r["VT_ID"]: vt_memo.get(r["VT_BEZ"]) for r in read_dbf(data_dir / "VORTEXT.DBF")}
memo = Memo(data_dir / "KFE.FPT")
positions = []
for r in read_dbf(data_dir / "KFE.DBF"):
roh = []
for i in range(1, 5):
art = r.get(f"ROHART{i}", "")
if art:
roh.append([art, num(r[f"ROHGEW{i}"]), num(r[f"ROHBASIS{i}"]), num(r[f"ROHKORR{i}"])])
lg = r.get("LOHNGR", "")
dates = [d for d in (r["INFO_DAT"], r["KNOTIZ_DAT"], r["PNOTIZ_DAT"]) if d]
vt = r["METAVTNR"] if r["METAVTNR"] in vortexte else ""
positions.append({
"p": r["LPOSNR"],
"a": r["LPOSNR_A"],
"me": r["MEH"],
"ae": num(r["AE"]),
"mat": num(r["MAT"]),
"i": r["INFO"],
"k": memo.get(r["KNOTIZ"]),
"z": memo.get(r["PNOTIZ"]),
"v": vt,
"lg": lg if lg in ("11", "12") else "10",
"d": iso(max(dates)) if dates else "",
"r": roh,
})
positions.sort(key=lambda x: x["p"])
payload = {
"meta": {"params": load_params(data_dir), "meh": MEH, "quelle": str(data_dir)},
"groups": groups,
"vortexte": vortexte,
"positions": positions,
"projects": load_projects(data_dir / "Projekt"),
}
print(f" {len(positions)} Positionen, {len(vortexte)} Vortexte, {len(groups)} Gruppen, "
f"{len(payload['projects'])} Projekte geladen ({time.time() - t0:.1f}s)")
return payload
# --------------------------------------------------------------------------- HTTP
def make_handler(index_html, data_json):
data_gz = gzip.compress(data_json, 6)
etag = '"' + hashlib.md5(data_json).hexdigest()[:16] + '"'
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def send(self, code, body, ctype, extra=None):
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def do_GET(self):
path = self.path.split("?", 1)[0]
if path in ("/", "/index.html"):
# index.html bei jedem Aufruf neu lesen, damit Änderungen ohne Neustart wirken
html = (HERE / "index.html").read_bytes() if (HERE / "index.html").exists() else index_html
self.send(200, html, "text/html; charset=utf-8", {"Cache-Control": "no-cache"})
elif path == "/api/data":
if self.headers.get("If-None-Match") == etag:
self.send_response(304)
self.end_headers()
return
hdr = {"ETag": etag, "Cache-Control": "no-cache"}
if "gzip" in self.headers.get("Accept-Encoding", ""):
hdr["Content-Encoding"] = "gzip"
self.send(200, data_gz, "application/json", hdr)
else:
self.send(200, data_json, "application/json", hdr)
else:
self.send(404, b"Not found", "text/plain")
do_HEAD = do_GET
return Handler
def main():
ap = argparse.ArgumentParser(description="Web-Viewer für die ZVEH-Kalkulationshilfe (KFE)")
env = os.environ.get
ap.add_argument("--data", default=env("KFE_DATA", str(DEFAULT_DATA)),
help="Verzeichnis mit KFE.DBF (env KFE_DATA, default: %(default)s)")
ap.add_argument("--host", default=env("KFE_HOST", "127.0.0.1"),
help="Bind-Adresse, 0.0.0.0 für Zugriff im LAN (env KFE_HOST)")
ap.add_argument("--port", type=int, default=int(env("KFE_PORT", "8765")), help="Port (env KFE_PORT)")
ap.add_argument("--no-browser", action="store_true", default=env("KFE_NO_BROWSER") == "1",
help="Browser nicht automatisch öffnen (env KFE_NO_BROWSER=1)")
ap.add_argument("--static", metavar="DATEI", help="Einzelne HTML-Datei mit eingebetteten Daten erzeugen")
args = ap.parse_args()
print("KFE-Viewer: lade Daten aus", args.data)
payload = load(args.data)
data_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
index_html = (HERE / "index.html").read_bytes()
if args.static:
# "</" escapen, damit Texte im JSON das <script>-Tag nicht beenden können
inline = data_json.decode("utf-8").replace("</", "<\\/")
html = index_html.decode("utf-8").replace(
"<!--KFE_DATA-->", f"<script>window.KFE_DATA={inline};</script>", 1)
Path(args.static).write_text(html, "utf-8")
print(f" Statische Datei geschrieben: {args.static} ({len(html) / 1e6:.1f} MB)")
return
server = ThreadingHTTPServer((args.host, args.port), make_handler(index_html, data_json))
url = f"http://{'127.0.0.1' if args.host in ('0.0.0.0', '') else args.host}:{args.port}/"
print(f" Läuft auf {url} (Strg+C zum Beenden)")
if not args.no_browser:
webbrowser.open(url)
# SIGTERM (z.B. "docker stop") wie Strg+C behandeln – als PID 1 würde Python es sonst ignorieren
def stop(*_):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nBeendet.")
if __name__ == "__main__":
main()

4
start.bat Normal file
View file

@ -0,0 +1,4 @@
@echo off
cd /d "%~dp0"
python server.py %*
pause