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
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
*
|
||||||
|
!server.py
|
||||||
|
!index.html
|
||||||
3
.env.example
Normal file
3
.env.example
Normal 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
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.bat text eol=crlf
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# KFE-Daten sind lizenzpflichtig und gehören nicht ins Repo
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
76
README.md
Normal 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
12
compose.yaml
Normal 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
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>
|
||||||
322
server.py
Normal file
322
server.py
Normal 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
4
start.bat
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
python server.py %*
|
||||||
|
pause
|
||||||
Loading…
Add table
Add a link
Reference in a new issue