Noch kein Konto? Jetzt kostenlos registrieren

Dashboard

Umsatz (letzte 12 Monate)

Überfällige Aufgaben

Aktuelle Aktivitäten

🔗 Project Core — Entity Registry & Verknüpfungen

📊 Entity Registry

Lade...

🤖 KI-Context Status

Lade...

🔄 Domain Events (Outbox)

Lade...

📋 Datenketten-Monitor

Lead→Project LV→Procure→Pay Plan→Execute Time→Cost Defect→Accept Change-Order Bill→Collect Knowledge→AI

📊 Erweiterte Auswertungen — Mängel · Zeit · Cashflow · Warnungen

🔧 Mängel-Quote

Lade...

⏱ Zeiterfassung (30 Tage)

Lade...

⚠️ Warnungen

Lade...

💰 Cashflow Soll/Ist (12 Monate)

Lade...

❤️ Projektgesundheit

Lade...

Projekte

Titel Priorität Status Zugewiesen Fällig am
Nummer Typ Status Betrag Empfänger Fällig am Aktionen

Projektdokumente

Neue Notiz

Auto-LV erstellen (KI)

KI analysiert alle Projektdokumente und erstellt automatisch ein LV, aufgeteilt nach Gewerken (Elektro, Sanitär, Heizung, etc.)

Projektplan generieren (KI)

KI erstellt einen detaillierten Bauzeitenplan mit Phasen, Meilensteinen und Abhängigkeiten

Projekt-KI-Assistent

Frage die KI zu deinem Projekt — sie kennt alle Dokumente, Aufgaben, LVs und Kosten.
z.B. "Erstelle eine Kostenübersicht nach Gewerken" oder "Welche Risiken siehst du?"

Leistungsverzeichnisse & Gewerk-Pakete

Keine LVs vorhanden. Klicke "Auto-LV erstellen" um eines aus den Projektdaten generieren zu lassen.

Gewerk-Pakete

Projektplanung & Timeline

Keine Planungen vorhanden. Klicke "KI-Plan generieren" für eine automatische Projektplanung.

Preisanfragen an Händler

Gesamt
0
Gesendet
0
Beantwortet
0
Bestes Angebot
-
HändlerGewerkKontaktStatusAngebotssummeAktionen

Baustellen

Aufgaben

⭘ Offen
⚙ In Bearbeitung
✓ Erledigt
🚫 Blockiert

Personal

Name Position Qualifikation Status Stundensatz Telefon Aktionen

Material

Name Kategorie Bestand Mindestbestand Preis (€) Lieferant Lagerort Aktionen

Finanzen

Einnahmen vs Ausgaben

Cashflow (letzte 12 Monate)

Rechnungen

Nummer Typ Status Betrag Empfänger Fällig am Aktionen

DATEV Export Buchhaltung

Letzte Exporte

Datum Typ Kontenrahmen Einträge Status

Konten-Mapping

Beschreibung SKR03 SKR04
Forderungen
Umsatzerlöse (19%)
Materialkosten (19%)
Verbindlichkeiten
Umsatzsteuer
Vorsteuer

Kunden

Firma Ansprechpartner E-Mail Telefon Typ Projekte Umsatz Status Aktionen
Gesamt-Dokumente Alle Projekte
1.248 +84 gegenüber Vorwoche
Heute hochgeladen Seit 00:00 Uhr
19 7 Dateien im Review
Speicherverbrauch Belegte Kapazität
187,4 GB 72 % des DMS-Speichers
Ausstehende Freigaben Offene Entscheidungen
12 4 überfällig

Dateibrowser

Aktueller Pfad: Alle Projekte / Projekt Rheinblick / Pläne

Name Typ-Icon Projekt Kategorie Hochgeladen am Größe Version Status Aktionen
HLS UG Review
PDF Projekt Rheinblick Pläne 05.04.2026 16:22 3,8 MB v3 In Review
Vertrag Nachtrag
DOC Projekt Rheinblick Verträge 04.04.2026 11:08 912 KB v2 Freigegeben
Aufmaß TGA
XLS Projekt Nordstern Rechnungen 06.04.2026 08:41 146 KB v1 Entwurf
Bestand CAD
DWG Projekt Nordstern Pläne 02.04.2026 14:57 12,4 MB v8 Abgelehnt

KI-Agents

Verfügbare KI-Modelle

Agents

BauGenio Chat

Wähle einen Agent und stelle eine Frage...

KI-Assistent

LV & Angebots-Tool

LV-Projekte
0
In Bearbeitung
0
Gesamtvolumen Netto
0 €
Gesamtvolumen Brutto
0 €

Leistungsverzeichnisse

NameAuftraggeberVergabenr.PositionenNettoBruttoStatusAktionen

Planungs-Tool (KI)

Planungen
0
Aktiv
0
Abgeschlossen
0

Projektplanungen

NameProjektStartEndePhasenStatusAktionen

Kalender

Monatsplanung, Terminserien und Tagesdetails in einer Oberfläche.

April 2026

Mo
Di
Mi
Do
Fr
Sa
So

E-Mail-Templates

Vorlagen für Ereignisse, Kommunikation und automatisierte Workflows verwalten.

Templates gesamt
12
Aktive Trigger
8
Testversand heute
5
Fehlerquote
0%
Name Trigger-Event Betreff Status Letzter Test Aktionen
Angebot freigegeben
Standard-Workflow Vertrieb
angebot_freigegeben Ihr Angebot für {{projekt_name}} ist freigegeben Aktiv 02.04.2026, 16:12
Termin-Erinnerung
Kalender / Disposition
termin_erinnert Erinnerung: {{termin_titel}} am {{termin_datum}} Aktiv 03.04.2026, 09:21
Abnahme-Ankündigung
Projektabschluss
abnahme_geplant Abnahme für {{projekt_name}} am {{abnahme_datum}} Inaktiv

Benachrichtigungs-Historie

Zentrale Übersicht aller System-, Projekt- und Terminmeldungen.

Ungelesen
3
Heute
9
Projektmeldungen
27
Terminmeldungen
14
Status Typ Titel Nachricht Zeitpunkt Aktionen
Ungelesen Projekt Neues Angebot freigegeben Projekt „Neubau Musterstraße“ wurde zur Freigabe markiert. 06.04.2026, 13:55
Ungelesen Termin Termin startet in 30 Minuten Baubesprechung TGA – 14:00 Uhr, Raum 2 / Meet-Link verfügbar. 06.04.2026, 13:30
Gelesen E-Mail E-Mail-Testversand erfolgreich Template „Abnahme-Ankündigung“ wurde an info@firma.de gesendet. 05.04.2026, 17:42

Nachrichten

Gesamt
0
Ungelesen
0
Wichtig
0
Gesendet
0

Berichte & Export-Center

Standardisierte Auswertungen, PDFs und Excel-Exporte zentral ausloesen und nachverfolgen.

📊

Projektstatus-Excel

Projektfortschritt, Meilensteine und Soll-Ist-Stand als Excel-Export.

📄

Abschluss-PDF

Abschlussdokumentation fuer Projektuebergabe und interne Ablage.

📝

Bautagebuch-PDF

Tagesberichte, Wetter, Personal und besondere Vorkommnisse als PDF.

⚠️

Maengelliste-PDF

Offene Maengel, Prioritaeten und Bearbeitungsstatus uebersichtlich exportieren.

📈

Dashboard-PDF

Management-Snapshot mit KPIs, Charts und Projektkennzahlen.

💶

Finanz-Excel

Kosten, Erlöse, Deckungsbeitraege und Zahlungsstatus je Projekt.

👷

Personal-Excel

Personaleinsatz, Stunden, Qualifikationen und Kapazitaeten gesammelt exportieren.

📚

Controlling-PDF

Kennzahlen fuer Geschaeftsfuehrung, Soll-Ist-Abweichungen und Risiken.

Download-Historie
Letzte Exporte mit Parametern, Status und Download-Zeitpunkt.
Bericht Projekt Zeitraum Status Erstellt am Aktion
Keine Historie geladen.

Benutzerverwaltung

Gesamt Benutzer

0

Aktive Benutzer

0

Administratoren

0

Letzte Registrierung

-

NameE-MailRollePositionStatusLetzter LoginAufgabenAktionen

Einstellungen

Firmendaten

Bankverbindung (für PDF-Rechnungen)

SMTP-Konfiguration

Mein Profil

A
Admin
Administrator

Darstellung

Passwort ändern

Zwei-Faktor-Authentifizierung

Aktivieren Sie Zwei-Faktor-Authentifizierung (2FA) mit einer Authentifizierungs-App für zusätzliche Sicherheit.

Status
Wird geladen...
-

Sitzungsinformationen

Sitzungsinformationen

Letzter Login
-
Token gültig bis
-

Benachrichtigungs-Kanäle

E-Mail Benachrichtigungen
Benachrichtigungen per E-Mail erhalten
Push-Benachrichtigungen
Browser-Push-Benachrichtigungen aktivieren

Benachrichtigen bei

Ruhezeiten

Keine Benachrichtigungen während dieser Zeiten senden.

bis

System-Informationen

Version
v4.0.0
Datenbank
SQLite3 WAL
API-URL
/api/v1

Wartung

Datensicherung (Backups)

Letzte Sicherung
Keine Sicherung vorhanden
DB-Größe
0 Tabellen
Sicherungen
0
von 7 gespeichert

Verfügbare Sicherungen

0 Einträge
Laden...

KI-API Konfiguration

Verbinde BauGenio mit externen KI-Diensten für erweiterte Funktionen.

Benutzer-Verwaltung

Benutzer erstellen, bearbeiten und verwalten.

Lade Benutzer...

Webhooks

Externe Systeme in Echtzeit benachrichtigen.

Lade Webhooks...

Angebote

Gesamt
0
Offen
0
Angenommen
0
Volumen (Brutto)
0 €
Nummer Kunde Projekt Status Netto Brutto Gültig bis Aktionen

Nachträge

Gesamt
0
Offen
0
Genehmigt
0
Volumen (Netto)
0 €
Nummer Projekt Titel Typ Kosten Status Frist Aktionen

Übersicht

Stand: -
Offene Mängel 0 Aktuell offen
Überfällige 0 Sofortiger Handlungsbedarf
Heute fällig 0 Frist endet heute
Erledigungsquote 0% Abgeschlossene Mängel

Filter

Mängelliste

0 Einträge
Aktionen

Keine Mängel vorhanden

Es wurden aktuell keine Einträge gefunden.

Seite 1 von 1
Gesamt Abnahmen
0
Bestanden
0
Mängel gesamt
0
Offene Mängel
0

Abnahme-Typen

Teilabnahmen
0
Gewerkeabnahmen
0
Schlussabnahmen
0
VOB-Abnahmen
0

Mängel-Status

Offen 0
In Bearbeitung 0
Behoben 0
Geprüft 0

Mängel-Statistik

Chart.js Pie

Mängel-Tracking

ID Beschreibung Status Priorität Frist Aktion
Keine Mängel vorhanden

Abnahmen Übersicht

Nr. Projekt Typ Datum Gewerk Status Mängel Aktion
Keine Abnahmen vorhanden
Baubesprechung
0
Planung
0
Abnahme
0
Sicherheit
0

Protokoll-Typen

Protokollübersicht

Nr. Titel Typ Datum Ort Teilnehmer TOPs Aktion
Keine Protokolle vorhanden

Cashflow

Einnahmen (Ist)
0 €
Ausgaben (Ist)
0 €
Saldo
0 €
Prognose (6M)
0 €

Monatlicher Cashflow

Datum Typ Kategorie Betrag Beschreibung Referenz Aktionen

Angebot - Positionen-Editor

Nr Beschreibung Einheit Menge Einheitspreis Gesamtpreis Typ Aktionen
Netto
0,00 €
MwSt 19%
0,00 €
Brutto
0,00 €

Nachträge - Genehmigungsprozess

Genehmigungsprozess

Ausstehende Genehmigungen

VOB & KI-Detection

VOB-Konformität

KI-Analyse: Erkannte Muster & Vorschläge

Budget-Tracking (Nachträge vs. Urvertrag)

Abnahmen - Fotos

Abnahmen
0
Fotos
0
Offline
0

Abnahme für Fotoerfassung

Kamerazugriff

Foto-Galerie

Keine Fotos vorhanden

Abnahmen - PDF & Signaturen

Unterzeichnet
0
Mängel
0
PDFs
0

Abnahmeprotokoll

Auftraggeber-Signatur

Auftragnehmer-Signatur

PDF-Vorlagen

Protokolle - Audio & Transkription

Protokolle
0
Audio-Dateien
0
Aufgaben
0
📁 Dateien hier ablegen oder klicken

Automatische Aufgaben

Aufgabe Status

Protokolle - Serienbesprechungen

Serien
0
Protokolle
0
Teilnehmer
0

Neue Besprechungsserie

Cashflow - Erweiterte Analyse

Cashflow-Status
€0
Prognose (3M)
€0
Warnung
0

Szenarien-Analyse

Liquidität & Mahnwesen

Offene Rechnungen
0
Überfällig
0
Gesamtwert
€0

Mahnstufen-Management

Monat Jahr

Mo
Di
Mi
Do
Fr
Sa
So

Tagesübersicht

Ausgewähltes Datum -
Wetter -
Personal 0
Geräte 0

Wetterdaten

Temperatur
-
Niederschlag
-
Wind
-
Bemerkung
-

Tagesberichte

Datum Wetter Personal Geräte Fortschritt Vorkommnisse Fotos Aktion
Keine Bautagebuch-Einträge vorhanden

Plantafel

Wochenansicht fuer Personal- und Geraeteplanung mit Konflikt- und Auslastungspruefung.

KW -- / ----
Auslastungsgrad
0%
Durchschnitt ueber alle Ressourcen
Kapazitaeten frei
0
Verfuegbare Slots in der Woche
Konflikte
0
Doppelbelegungen / Ueberschneidungen
Ressourcen gesamt
0
Mitarbeiter und Geraete in Planung
Filter
Baustelle, Qualifikation und Ressourcentyp eingrenzen.
Wochenplanung
X = Tage, Y = Mitarbeiter und Geraete.
Ressource Mo Di Mi Do Fr Sa So Auslastung
Keine Plantafel-Daten geladen.
Auslastungs-Ampel
Bewertung pro Ressourcenzeile.
Unter 70%
0
70% - 90%
0
Ueber 90% / Konflikt
0
Wochenauslastung
Aggregierte Auslastung je Tag.
Konflikte
Erkannte Ueberschneidungen und Kapazitaetsprobleme.
Ressource Tag Konflikt
Keine Konflikte geladen.

Fuhrpark

Geraete, Verfuegbarkeit, Wartung und Einsatzhistorie zentral verwalten.

Gesamtbestand
0
Alle Fahrzeuge und Geraete
Im Einsatz
0
Aktiv auf Baustellen disponiert
Verfuegbar
0
Kurzfristig einsetzbar
Wartung / Sperre
0
Wartung faellig oder aktuell gesperrt

Geraete-Uebersicht

Bezeichnung Typ Status Standort Wartung Aktion
Keine Geraete geladen.
Wartungs-Ampel
Faellige und kritische Wartungen im Blick.
OK (> 30 Tage)
0
Bald faellig (7 - 30 Tage)
0
Kritisch (<= 7 Tage / ueberfaellig)
0
Verteilung nach Status
Schneller Status-Ueberblick.

Baustellenfotos

Fotos gesamt
0
Baustellen mit Fotos
0
Letzte Woche
0

Projektsteuerung

Aktives Projekt auswählen und Kennzahlen für das Controlling laden.

Budget gesamt account_balance_wallet
0,00 €
Stand: –
Verbraucht payments
0,00 €
0,0 % vom Budget
Prognose Endkosten query_stats
0,00 €
Erwarteter Projektabschluss
Abweichung trending_up
0,00 €
0,0 %
Fertigstellungsgrad task_alt
0,0 %
Noch keine Projektdaten geladen

Projekt-Gesundheit

Ampel-System für Kosten, Zeit und Qualität.

Gesamtstatus: Unbekannt

Kosten

Keine Bewertung vorhanden

Zeit

Keine Bewertung vorhanden

Qualität

Keine Bewertung vorhanden

Budget-Übersicht nach DIN 276

Budget-Soll, Beauftragungen, Ist-Kosten, Prognose und Kommentierung je Kostengruppe.

Kostengruppe Budget-Soll Beauftragt Ist-Kosten Prognose Abweichung Abw. % Kommentar
inventory_2 Noch keine Budgetdaten geladen
Gesamt 0,00 € 0,00 € 0,00 € 0,00 € 0,00 € 0,0 %

Soll-Ist-Vergleich

Gestapelter Vergleich von Soll, Ist und Prognose pro Kostengruppe.

Soll Ist Prognose

Kostenentwicklung

Monatliche Entwicklung von Budget, Ist-Kosten und Endkostenprognose.

Zeitraum: –

Nachträge

Status und finanzielle Wirkung aller Nachträge im Projekt.

Gesamtvolumen: 0,00 € Offen: 0 Genehmigt: 0
Nachtragsnr. Beschreibung Betrag Status Auftragnehmer Datum
receipt_long Noch keine Nachträge geladen

Cashflow-Prognose

Monatliche Ein- und Auszahlungen inklusive Liquiditätssaldo.

Einzahlungen: 0,00 € Auszahlungen: 0,00 € Saldo: 0,00 €
Monat Einzahlungen Auszahlungen Saldo Kumuliert
monitoring Noch keine Cashflow-Daten geladen

Nachkalkulation je Gewerk

Wirtschaftliche Auswertung nach LV-Summe, Aufmaß, Nachträgen und Ist-Kosten.

Gewerke: 0 Gesamt-Ergebnis: 0,00 €
Gewerk LV-Summe Aufmaß-Summe Nachträge Ist-Kosten Ergebnis
calculate Noch keine Nachkalkulationsdaten geladen
Gesamt 0,00 € 0,00 € 0,00 € 0,00 € 0,00 €

Controlling-Kennzahlen

Projektübergreifende Statistik und Benchmarks aus dem Controlling.

Aktive Projekte 0 Datenquelle: /api/v1/controlling/statistik
Durchschn. Budgetabweichung 0,0 % Portfolio-Sicht
Genehmigungsquote Nachträge 0,0 % Genehmigt vs. gesamt
Durchschn. Fertigstellungsgrad 0,0 % Über alle Projekte

Bericht & Export

Managementbericht für Bauherr, Projektleitung oder interne Nachkalkulation erzeugen.

Berichtsumfang

Export-Aktionen

Der Bericht wird auf Basis des ausgewählten Projekts und des aktuellen Stichtags erzeugt.

Projekt:
Stichtag:
Letzte Aktualisierung:

Bestellwesen & Einkauf

Bestellungen gesamt
0
Offene
0
Gesamtwert
€0

Team-Chat

Kanäle
Kanal wählen...
Gesamt Events
-
Heute
-
Deletes
-
Creates
-
Gesamtrollen
0
Benutzerdefinierte Rollen
0
Rollenzuweisungen
0
Rolle Beschreibung Benutzer Aktionen
Artikel gesamt
0
Aktive Materialstammdaten
Warengruppen
0
Strukturierte Katalogsegmente
Preisänderungen 30T
0
Artikel mit neuem EK/VK
Leistungen
0
Aktive Leistungspositionen

Artikelliste

0 Einträge

Artikel-Nr. Bezeichnung Warengruppe Einheit EK aktuell Letzte Preisänderung Status Aktionen

KPI-Dashboard

Aktueller Status aller Eingangsrechnungen und Zahlungsrisiken.

Rechnungen gesamt 0 Alle erfassten Rechnungen
Offen 0,00 € Noch nicht bezahlt
Überfällig 0,00 € Sofortiger Handlungsbedarf
Diesen Monat bezahlt 0,00 € Auszahlungen im laufenden Monat
Skonto-Potential 0,00 € Noch nutzbares Einsparpotenzial

Filter & Suche

Lieferanten, Projekte, Status, Rechnungsarten und Betragsbereiche gezielt filtern.

Rechnungsübersicht

0 Treffer im aktuellen Filter.

Eingegangen Geprüft Freigegeben Bezahlt Reklamiert
RE-Nr Lieferant Projekt Rechnungsdatum Fällig am Netto Brutto MwSt Skonto-Frist Status Prüfer Aktionen

Noch keine Eingangsrechnungen geladen.

Zahlungsplan-Übersicht

Fälligkeiten und Skonto-Fenster im Blick behalten.

Fälligkeiten-Kalender

Visualisierung der anstehenden Zahlungstermine.

Monat Jahr
Fällig Überfällig Skonto-Frist
Mo Di Mi Do Fr Sa So

Nächste Fälligkeiten

Datum RE-Nr Lieferant Projekt Betrag Status
Keine anstehenden Fälligkeiten.

Skonto-Tracker

Rechnungen mit aktivem Einsparpotenzial und Fristüberwachung.

Aktives Skonto-Potential 0,00 €
Fristen in 7 Tagen 0
RE-Nr Lieferant Skonto bis Skonto (%) Potential Status Aktion
Keine Skonto-Fälle vorhanden.

Lieferscheinliste

0 Einträge

Lieferscheinnr. Lieferant Lieferdatum Projekt / Baustelle Positionen Verknüpfte Rechnung Status Aktionen

CRM Pipeline

Leads, Vertriebsphasen, Kommunikation und Umsatzpotenzial im Überblick.

Kunden gesamt
0
Leads Monat
0
Conversion-Rate
0 %
Pipeline-Umsatz
0 €
Zuletzt aktualisiert: –

Lead

0

Interessent

0

Angebot

0

Verhandlung

0

Kunde

0

Bestandskunde

0
Offene Bestellungen 0 0 Bestellungen offen
Wareneingang erwartet 0 Nächste 7 Tage
Bestellvolumen Monat 0,00 € Aktueller Monat
Überfällige Bestellungen 0 0 Positionen betroffen

Material-Warnungen

Kritische Bestände und Engpässe

0
Keine Material-Warnungen vorhanden.
Material Projekt Bestand Meldebestand Priorität Aktion

Auto-Bestellvorschläge

Vorschläge aus Warnungen, Reservierungen und offenen Bedarfen

Keine Bestellvorschläge vorhanden.
Material Projekt Vorschlag Lieferant Zieltermin Aktion

Erwarteter Wareneingang

Offene Bestellungen mit baldiger Lieferung

Keine erwarteten Wareneingänge.
Bestellung Lieferant Projekt Liefertermin Offener WE

Überfällige Bestellungen

Bestellungen mit überschrittenem Liefertermin

Keine überfälligen Bestellungen.
Bestellung Lieferant Tage überfällig Offener Wert Aktion
Gesamtumsatz 30 Tage
-
Lade Daten...
Gesamtkosten 30 Tage
-
Lade Daten...
Deckungsbeitrag Live
-
Lade Daten...
Aktive Projekte Heute
-
Lade Daten...
Offene Angebote Pipeline
-
Lade Daten...
Ueberfaellige Aufgaben Achtung
-
Lade Daten...

Umsatz vs Kosten

Monatlicher Verlauf

Projekt-Status

Verteilung nach Status

Aktivitaets-Feed

Letzte System- und Benutzeraktionen

Aktivitaeten werden geladen...

Quick Actions

Schneller Einstieg in Kernprozesse

KPI-Dashboard

Status, Volumen und Bearbeitungsstand aller Aufmaßblätter.

Aufmaßblätter gesamt
0
Alle Vorgänge
Offen
0
Entwürfe / unbearbeitet
In Prüfung
0
Eingereicht / geprüft
Freigegeben
0
Abrechenbar
Gesamtvolumen netto
0,00 €
Über alle Aufmaßblätter

Statusverteilung

Volumenentwicklung

Filter-Leiste

Projekt, Gewerk, Status, Auftragnehmer und Zeitraum gezielt einschränken.

Aufmaß-Übersicht

Alle Aufmaßblätter mit Status, Volumen, Datum und direkten Aktionen.

0 Einträge
Nr. Projekt Gewerk Auftragnehmer Positionen Summe netto Status Datum Aktionen
Keine Aufmaßblätter vorhanden.

Aufmaß-Detailansicht

Kopfdaten, Positionen, Mengenermittlung, Soll/Ist-Vergleich und Workflow in einer Ansicht.

Kopfdaten

Entwurf

Zusammenfassung & Workflow

Positionen 0
Menge gesamt 0,000
Netto gesamt 0,00 €
Geprüft 0,00 €
Freigegeben 0,00 €

Entwurf Bearbeitung intern
Eingereicht Zur Prüfung übergeben
Geprüft Sachlich geprüft
Freigegeben Abrechenbar

Positionsliste

Detailpositionen mit VOB-konformen Berechnungsformeln, Mengen, Preisen und Status.

VOB-Hinweis: Rechenweg, Zuschläge, Abzüge und Aussparungen je Position nachvollziehbar dokumentieren. Öffnungen und Abzüge separat ausweisen.
Pos. Beschreibung Formel / Mengenermittlung LV-Pos. Einheit Soll Ist Abzug EP netto GP netto Status Aktionen
Noch keine Positionen erfasst.
Gesamtsumme netto 0,00 €

Formel-Rechner

Inline-Kalkulation mit Zwischensummen, Zuschlägen, Abzügen und dokumentiertem Rechenweg.

Aktive Position

Keine Position gewählt

Zwischensummen & Ergebnis

Basisformel 0,000
Abzüge absolut 0,000
Zwischensumme 0,000
Zuschlag 0,000
Abzug % 0,000
Endmenge 0,000
Gesamtpreis netto 0,00 €

Keine Berechnung vorhanden.

Rechenzeilen

Bezeichnung L B H Anzahl Menge Art
Keine Rechenzeilen vorhanden.

Vergleichsansicht: Aufmaß vs. LV

Soll/Ist-Vergleich pro LV-Position mit Mengen-, Preis- und Abweichungsanalyse.

Abweichungsübersicht

LV-Positionen 0
Mit Abweichung 0
Über Soll 0
Unter Soll 0

Soll/Ist-Tabelle

LV-Pos. Beschreibung Soll-Menge Ist-Menge Abweichung Soll-Wert Ist-Wert Status
Noch kein Vergleich verfügbar.

Freigabe-Workflow & Verlauf

Lückenlose Dokumentation aller Statuswechsel, Kommentare und Prüfschritte.

Workflow-Aktion

Verlauf / Audit Trail

Noch kein Workflow-Verlauf vorhanden.
\n `); printWindow.document.close(); printWindow.focus(); setTimeout(() => { try { printWindow.print(); } catch(e) {} }, 300); } // ──────────────────────────────────────────────────────────────── // BauGenioProjektCockpit (Phase 2 Batch 1 - BG-M06) // Project detail view: KPI tiles + 8 tabs with lazy-loading // ──────────────────────────────────────────────────────────────── (function() { const moduleName = 'BauGenioProjektCockpit'; let state = { project: null, projectId: null, currentTab: 'uebersicht' }; const init = async (container, projectId) => { state.projectId = projectId; console.log(`[${moduleName}] Initializing cockpit for project ${projectId}`); const div = document.createElement('div'); div.className = 'cockpit-container'; div.innerHTML = `

Projekt-Cockpit

Umsatz
0 EUR
Ausstehend
0 EUR
Kosten
0 EUR
Marge %
0%
Aufmaß-Summe
0
Dokumente
0

Aktualisierung...

`; container.appendChild(div); // Load cockpit data await loadCockpitData(); // Setup tab switching document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', (e) => switchTab(e.target.dataset.tab)); }); }; const loadCockpitData = async () => { try { const resp = await fetch(`/api/v1/projects/${state.projectId}/cockpit`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}` } }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); state.project = await resp.json(); renderKPIs(); renderTab(state.currentTab); } catch (e) { console.error(`[${moduleName}] Error loading cockpit:`, e); document.getElementById('cockpit-title').textContent = 'Fehler beim Laden'; } }; const renderKPIs = () => { const kpis = state.project.kpis || {}; document.getElementById('kpi-umsatz').textContent = safeText(formatCurrency(kpis.umsatz || 0)); document.getElementById('kpi-ausstehend').textContent = safeText(formatCurrency(kpis.ausstehend || 0)); document.getElementById('kpi-kosten').textContent = safeText(formatCurrency(kpis.kosten || 0)); document.getElementById('kpi-marge').textContent = safeText(`${(kpis.marge_pct || 0).toFixed(1)}%`); document.getElementById('kpi-aufmass').textContent = safeText(kpis.aufmass_summe || 0); document.getElementById('kpi-dokumente').textContent = safeText(kpis.dokument_count || 0); }; const switchTab = (tabName) => { state.currentTab = tabName; document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none'); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); renderTab(tabName); }; const renderTab = (tabName) => { const content = document.getElementById(`tab-${tabName}`); if (!content || !state.project) return; let html = ''; switch(tabName) { case 'uebersicht': html = `

Letzte Logbook-Einträge

`; break; case 'finanzen': html = `
TypBetrag
Umsatz${safeText(formatCurrency(state.project.kpis.umsatz))}
Ausstehend${safeText(formatCurrency(state.project.kpis.ausstehend))}
Kosten${safeText(formatCurrency(state.project.kpis.kosten))}
`; break; case 'dokumente': html = `

Dokumente (${(state.project.recent_documents || []).length})

`; break; default: html = `

Tab "${safeText(tabName)}" wird geladen...

`; } content.innerHTML = html; }; const refresh = loadCockpitData; const destroy = () => { state = { project: null, projectId: null, currentTab: 'uebersicht' }; }; window.BauGenioProjektCockpit = { init, destroy, refresh, loadCockpitData }; if (window.moduleBridge) window.moduleBridge.register(moduleName, { init, destroy, refresh }); })(); // ──────────────────────────────────────────────────────────────── // BauGenioCrmFunnel (Phase 2 Batch 1 - BG-E04) // Kanban board: Lead → Opportunity → Customer pipeline // HTML5 drag-drop, stages: new, qualified, proposal_sent, negotiation, won, lost // ──────────────────────────────────────────────────────────────── (function() { const moduleName = 'BauGenioCrmFunnel'; const stages = ['new', 'qualified', 'proposal_sent', 'negotiation', 'won', 'lost']; const stageLabels = { 'new': 'Neu', 'qualified': 'Qualifiziert', 'proposal_sent': 'Angebot gesendet', 'negotiation': 'Verhandlung', 'won': 'Gewonnen', 'lost': 'Verloren' }; let state = { opportunities: [], leads: [], draggedItem: null }; const init = async (container) => { console.log(`[${moduleName}] Initializing CRM funnel`); const div = document.createElement('div'); div.className = 'crm-funnel-container'; div.innerHTML = `

CRM-Funnel (Pipeline)

`; container.appendChild(div); document.getElementById('new-lead-btn').addEventListener('click', openNewLeadModal); await loadData(); renderKanban(); }; const loadData = async () => { try { const jwt = localStorage.getItem('jwt'); const oppResp = await fetch('/api/v1/crm/opportunities', { headers: { 'Authorization': `Bearer ${jwt}` } }); const leadResp = await fetch('/api/v1/crm/leads', { headers: { 'Authorization': `Bearer ${jwt}` } }); if (oppResp.ok) state.opportunities = (await oppResp.json()).opportunities || []; if (leadResp.ok) state.leads = (await leadResp.json()).leads || []; } catch (e) { console.error(`[${moduleName}] Error loading data:`, e); } }; const renderKanban = () => { const board = document.getElementById('kanban-board'); if (!board) return; let html = '
'; stages.forEach(stage => { const opps = state.opportunities.filter(o => o.stage === stage); html += `
${safeText(stageLabels[stage])} (${opps.length})
${opps.map(o => `
${safeText(o.title || 'Unbenannt')}
${safeText(formatCurrency(o.value || 0))}
${safeText(o.probability || 0)}% Wahrscheinlichkeit
`).join('')}
`; }); html += '
'; board.innerHTML = html; }; const handleDragStart = (e) => { state.draggedItem = { id: e.target.dataset.id, sourceStage: e.target.closest('.kanban-column').dataset.stage }; e.dataTransfer.effectAllowed = 'move'; e.target.closest('.kanban-column').classList.add('drag-over'); }; const handleDrop = async (e) => { e.preventDefault(); if (!state.draggedItem) return; const targetStage = e.currentTarget.dataset.stage; const jwt = localStorage.getItem('jwt'); try { const resp = await fetch(`/api/v1/crm/opportunities/${state.draggedItem.id}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ stage: targetStage }) }); if (resp.ok) { await loadData(); renderKanban(); } } catch (err) { console.error('Error updating opportunity stage:', err); } }; const openNewLeadModal = () => { const form = prompt('Neuer Lead - Firmenname:'); if (!form) return; createLead({ company: form }); }; const createLead = async (data) => { const jwt = localStorage.getItem('jwt'); try { const resp = await fetch('/api/v1/crm/leads', { method: 'POST', headers: { 'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (resp.ok) { await loadData(); renderKanban(); } } catch (e) { console.error('Error creating lead:', e); } }; const refresh = async () => { await loadData(); renderKanban(); }; const destroy = () => { state = { opportunities: [], leads: [], draggedItem: null }; }; window.BauGenioCrmFunnel = { init, destroy, refresh, loadData, renderKanban, handleDragStart, handleDrop, createLead }; if (window.moduleBridge) window.moduleBridge.register(moduleName, { init, destroy, refresh }); })(); // ============================================================================ // BauGenioWartung (BG-M03) // ============================================================================ (function() { window.BauGenioWartung = { init: async function() { console.log('BauGenioWartung init'); await this.loadData(); this.render(); this.attachEvents(); }, loadData: async function() { try { this.data = { plaene: (await fetch('/api/v1/wartungsplaene', {headers: {'Authorization': `Bearer ${jwt}`}}).then(r => r.json())), wartungen: (await fetch('/api/v1/wartungen', {headers: {'Authorization': `Bearer ${jwt}`}}).then(r => r.json())), faellig: (await fetch('/api/v1/wartungsplaene?faellig=true', {headers: {'Authorization': `Bearer ${jwt}`}}).then(r => r.json())) }; } catch (e) { console.error('Failed to load wartung data:', e); this.data = { plaene: [], wartungen: [], faellig: [] }; } }, render: function() { const html = `
${this.data.plaene.map(p => ` `).join('')}
GerätTypIntervallNächster TerminAktion
${p.geraet_id || 'Allgemein'} ${p.wartungs_typ} ${p.intervall_wert} ${p.intervall_typ} ${p.naechster_wartungstermin}
${this.data.wartungen.map(w => ` `).join('')}
PlanDatumKostenErgebnis
${w.wartungsplan_id} ${w.durchgefuehrt_am} €${w.kosten || 0} ${w.ergebnis}
${this.data.faellig.map(f => { const today = new Date(); const fuellig = new Date(f.naechster_wartungstermin); const tage = Math.ceil((fuellig - today) / (1000 * 60 * 60 * 24)); const overdue = tage < 0 ? 'overdue' : ''; return ` `; }).join('')}
GerätTypFällig amTage verbleibend
${f.geraet_id || 'Allgemein'} ${f.wartungs_typ} ${f.naechster_wartungstermin} ${tage > 0 ? tage + ' Tage' : 'ÜBERFÄLLIG'}
`; document.querySelector('[data-page="wartung"]').innerHTML = html; }, attachEvents: function() { const self = this; document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', function() { document.querySelectorAll('.tab-btn, .tab-content').forEach(e => e.classList.remove('active')); this.classList.add('active'); document.getElementById('tab-' + this.dataset.tab).classList.add('active'); }); }); document.getElementById('btn-new-plan').addEventListener('click', () => { const intervall = prompt('Intervall (Tage):', '90'); const typ = prompt('Wartungstyp (inspektion/service):', 'inspektion'); if (intervall && typ) { const naechster = new Date(); naechster.setDate(naechster.getDate() + parseInt(intervall)); fetch('/api/v1/wartungsplaene', { method: 'POST', headers: {'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json'}, body: JSON.stringify({ wartungs_typ: typ, intervall_typ: 'zeit', intervall_wert: parseInt(intervall), naechster_wartungstermin: naechster.toISOString().split('T')[0] }) }).then(r => r.json()).then(d => { alert('Plan erstellt'); self.init(); }); } }); document.getElementById('form-wartung').addEventListener('submit', async function(e) { e.preventDefault(); const planId = document.getElementById('plan-id').value; const body = { wartungsplan_id: planId, durchgefuehrt_am: document.getElementById('durchgefuehrt_am').value, kosten: parseFloat(document.getElementById('kosten').value) || 0, ergebnis: document.getElementById('ergebnis').value, bericht_text: document.getElementById('bericht_text').value }; await fetch('/api/v1/wartungen', { method: 'POST', headers: {'Authorization': `Bearer ${jwt}`, 'Content-Type': 'application/json'}, body: JSON.stringify(body) }).then(r => r.json()).then(d => { alert('Wartung erfasst'); document.getElementById('modal-wartung').classList.add('hidden'); self.init(); }); }); }, openWartungModal: function(planId) { document.getElementById('plan-id').value = planId; document.getElementById('modal-wartung').classList.remove('hidden'); }, deletePlan: function(planId) { if (confirm('Plan löschen?')) { fetch(`/api/v1/wartungsplaene/${planId}`, { method: 'DELETE', headers: {'Authorization': `Bearer ${jwt}`} }).then(r => { alert('Plan gelöscht'); this.init(); }); } } }; window.BauGenioWartung.init().catch(e => console.error('WartungModule error:', e)); })(); `); printWindow.document.close(); setTimeout(() => printWindow.print(), 250); } function exportReportPDF(type) { const reportTitle = document.getElementById('reportTitle').textContent; const reportContent = document.getElementById('reportContent').innerHTML; const printWindow = window.open('', '', 'height=600,width=900'); printWindow.document.write(` ${reportTitle}

${reportTitle}

Generiert: ${new Date().toLocaleString('de-DE')}
${reportContent.replace(/]*>.*?<\/button>/gi, '').replace(/style="[^"]*display:flex[^"]*"/gi, '')}
Bericht generiert von BauGenio v4.0.0 | ${new Date().getFullYear()}
`); printWindow.document.close(); setTimeout(() => printWindow.print(), 250); } function emailReport(type) { const reportTitle = document.getElementById('reportTitle').textContent; const subject = encodeURIComponent(`BauGenio Bericht: ${reportTitle}`); const timestamp = new Date().toLocaleString('de-DE'); const body = encodeURIComponent(`Sehr geehrte Damen und Herren,\n\nanbei finden Sie den angeforderten Bericht:\n\nBerichtstyp: ${reportTitle}\nGeneriert: ${timestamp}\n\nFreundliche Grüße,\nBauGenio System`); window.open(`mailto:?subject=${subject}&body=${body}`, '_blank'); showToast('E-Mail-Fenster wird geöffnet...', 'success'); } async function generateProjectReport() { const projectOpts = await getProjectOptions(); openModal('Projektbericht erstellen', `
`, async () => { const pid = document.getElementById('reportProjectId').value; if (!pid) { showToast('Bitte Projekt auswählen','error'); return; } closeModal(); try { const report = await apiCall(`/berichte?type=project&project_id=${pid}`); document.getElementById('reportTitle').textContent = report.title || 'Projektbericht'; document.getElementById('reportOutput').style.display = 'block'; const p = report.project; let html = `
Status
${p.status}
Fortschritt
${p.progress||0}%
Budget
${formatCurrency(p.budget||0)}
Ausgaben
${formatCurrency(p.spent||0)}
`; if (report.aufgaben && report.aufgaben.length) { html += `

Aufgaben (${report.aufgaben.length})

${report.aufgaben.map(a => ``).join('')}
TitelStatusPrioritätZugewiesen
${a.title}${a.status}${a.priority}${a.assigned_to||'-'}
`; } if (report.rechnungen && report.rechnungen.length) { html += `

Rechnungen (${report.rechnungen.length})

${report.rechnungen.map(r => ``).join('')}
NummerTypStatusBetrag
${r.nummer}${r.typ}${r.status}${formatCurrency(r.betrag)}
`; } document.getElementById('reportContent').innerHTML = html; } catch(e) { showToast('Bericht konnte nicht erstellt werden','error'); } }); } // ===== BENUTZERVERWALTUNG ===== let allUsers = []; let filteredUsers = []; let currentRoleFilter = ''; let currentSearchQuery = ''; async function loadBenutzer() { try { const users = await apiCall('/user-management'); allUsers = users || []; filteredUsers = allUsers; updateKPIs(); renderUserTable(); setupUserSearchListener(); } catch(e) { showToast('Benutzer konnten nicht geladen werden','error'); } } function updateKPIs() { const totalUsers = allUsers.length; const activeUsers = allUsers.filter(u => u.is_active).length; const admins = allUsers.filter(u => u.role === 'admin' || u.role === 'super_admin').length; const sortedByDate = [...allUsers].sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); const lastReg = sortedByDate[0]?.created_at ? formatDate(sortedByDate[0].created_at) : '-'; document.getElementById('kpiTotalUsers').textContent = totalUsers; document.getElementById('kpiActiveUsers').textContent = activeUsers; document.getElementById('kpiAdmins').textContent = admins; document.getElementById('kpiLastRegistration').textContent = lastReg; } function renderUserTable() { const roleLabels = {super_admin:'Super Admin',admin:'Admin',projektleiter:'Projektleiter',bauleiter:'Bauleiter',monteur:'Monteur',buchhalter:'Buchhalter',buero:'Büro'}; const roleColors = {admin:'#FF6B6B',super_admin:'#FF6B6B',bauleiter:'#4ECDC4',projektleiter:'#45B7D1',monteur:'#96CEB4',buchhalter:'#FFEAA7',buero:'#DDA15E'}; const html = filteredUsers.map(u => { const fullName = `${u.first_name||''} ${u.last_name||''}`.trim(); const roleLabel = roleLabels[u.role] || u.role; const roleColor = roleColors[u.role] || '#999'; const lastLoginTime = u.last_login ? relativeTime(u.last_login) : 'Nie'; const taskCount = u.task_count || 0; return ` ${fullName} ${u.email} ${roleLabel} ${u.position||'-'} ${u.is_active?'Aktiv':'Inaktiv'} ${lastLoginTime} ${taskCount} `; }).join(''); document.getElementById('usersList').innerHTML = html || 'Keine Benutzer gefunden'; } function filterUsers(role) { currentRoleFilter = role; applyFilters(); } function searchUsers(query) { currentSearchQuery = query.toLowerCase(); applyFilters(); } function applyFilters() { filteredUsers = allUsers.filter(u => { const matchesRole = !currentRoleFilter || u.role === currentRoleFilter; const fullName = `${u.first_name||''} ${u.last_name||''}`.toLowerCase(); const matchesSearch = !currentSearchQuery || fullName.includes(currentSearchQuery) || (u.email||'').toLowerCase().includes(currentSearchQuery) || (u.position||'').toLowerCase().includes(currentSearchQuery); return matchesRole && matchesSearch; }); renderUserTable(); } function setupUserSearchListener() { const searchInput = document.getElementById('userSearchInput'); if (searchInput) { searchInput.addEventListener('input', (e) => searchUsers(e.target.value)); } } function relativeTime(dateStr) { if (!dateStr) return 'Nie'; const now = new Date(); const date = new Date(dateStr); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return 'gerade eben'; if (diffMins < 60) return `vor ${diffMins} Minute${diffMins > 1 ? 'n' : ''}`; if (diffHours < 24) return `vor ${diffHours} Stunde${diffHours > 1 ? 'n' : ''}`; if (diffDays < 7) return `vor ${diffDays} Tag${diffDays > 1 ? 'en' : ''}`; return formatDate(dateStr); } async function showUserDetail(userId) { try { const users = await apiCall('/user-management'); const u = users.find(x => x.id === userId); if (!u) return; openModal('Benutzer bearbeiten', `
${window.__baugenio_context === 'settings' ? '' : ''}
`, async () => { const data = { first_name: document.getElementById('editUserName').value, email: document.getElementById('editUserEmail').value, role: document.getElementById('editUserRole').value, position: document.getElementById('editUserPosition').value, is_active: parseInt(document.getElementById('editUserStatus').value) === 1, phone: document.getElementById('editUserPhone').value, hourly_rate: parseFloat(document.getElementById('editUserHourlyRate').value) || null, notes: document.getElementById('editUserNotes').value, }; try { await apiCall(`/user-management/${userId}`, 'PATCH', data); closeModal(); showToast('Benutzer aktualisiert','success'); loadBenutzer(); } catch(e) { showToast('Aktualisierung fehlgeschlagen','error'); } }); } catch(e) { showToast('Fehler beim Laden des Benutzers','error'); } } async function deactivateUser(userId) { const u = allUsers.find(x => x.id === userId); if (!u) return; const newStatus = !u.is_active; try { await apiCall(`/user-management/${userId}`, 'PATCH', {is_active: newStatus}); showToast(newStatus ? 'Benutzer aktiviert' : 'Benutzer deaktiviert','success'); loadBenutzer(); } catch(e) { showToast('Statusänderung fehlgeschlagen','error'); } } async function resetUserPassword(userId) { try { await apiCall(`/user-management/${userId}/reset-password`, 'POST'); const infoEl = document.getElementById('passwordResetInfo'); infoEl.textContent = 'Ein Passwort-Reset-Link wurde an die E-Mail des Benutzers gesendet.'; infoEl.style.display = 'block'; showToast('Passwort-Reset-Link gesendet','success'); } catch(e) { showToast('Fehler beim Zurücksetzen des Passworts','error'); } } // ===== UTILITY FUNCTIONS ===== function formatCurrency(value) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2 }).format(value); } function formatDate(dateStr) { if (!dateStr) return ''; return new Date(dateStr).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' }); } function showToast(message, type = 'success', duration = 3000) { const existing = document.querySelectorAll('.toast-notification'); existing.forEach((t, i) => t.style.top = (16 + i * 56) + 'px'); const toast = document.createElement('div'); toast.className = 'toast-notification'; const colors = { success: { bg: '#059669', icon: '✓' }, error: { bg: '#dc2626', icon: '✕' }, warning: { bg: '#d97706', icon: '⚠' }, info: { bg: '#2563eb', icon: 'ℹ' } }; const c = colors[type] || colors.success; toast.innerHTML = `${c.icon} ${escapeHtml(message)}`; toast.style.cssText = `position:fixed;top:16px;right:16px;padding:12px 20px;background:${c.bg};color:white;border-radius:8px;font-size:13px;z-index:10000;box-shadow:0 4px 12px rgba(0,0,0,0.3);display:flex;align-items:center;animation:slideInRight 0.3s ease;max-width:400px;`; document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s ease'; setTimeout(() => toast.remove(), 300); }, duration); } // ─── Form Validation Framework ─── function validateForm(formData, rules) { const errors = []; for (const [field, fieldRules] of Object.entries(rules)) { const value = formData[field]; for (const rule of fieldRules) { if (rule.required && (!value || value.toString().trim() === '')) { errors.push({ field, message: rule.message || `${field} ist erforderlich` }); } if (rule.minLength && value && value.length < rule.minLength) { errors.push({ field, message: rule.message || `${field} muss mindestens ${rule.minLength} Zeichen haben` }); } if (rule.maxLength && value && value.length > rule.maxLength) { errors.push({ field, message: rule.message || `${field} darf maximal ${rule.maxLength} Zeichen haben` }); } if (rule.pattern && value && !rule.pattern.test(value)) { errors.push({ field, message: rule.message || `${field} hat ein ungültiges Format` }); } if (rule.email && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { errors.push({ field, message: rule.message || `${field} muss eine gültige E-Mail sein` }); } if (rule.min !== undefined && value !== '' && Number(value) < rule.min) { errors.push({ field, message: rule.message || `${field} muss mindestens ${rule.min} sein` }); } if (rule.max !== undefined && value !== '' && Number(value) > rule.max) { errors.push({ field, message: rule.message || `${field} darf maximal ${rule.max} sein` }); } } } return errors; } function showValidationErrors(errors) { // Clear previous errors document.querySelectorAll('.field-error').forEach(el => el.remove()); document.querySelectorAll('.input-error').forEach(el => el.classList.remove('input-error')); if (errors.length === 0) return true; errors.forEach(err => { const input = document.querySelector(`[name="${err.field}"], #${err.field}`); if (input) { input.classList.add('input-error'); const errorEl = document.createElement('div'); errorEl.className = 'field-error'; errorEl.textContent = err.message; input.parentNode.insertBefore(errorEl, input.nextSibling); } }); showToast(errors[0].message, 'error'); return false; } // ─── Debounce Utility ─── function debounce(func, wait = 300) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } // ─── Number Formatting for Currency Inputs ─── function formatNumberInput(input) { input.addEventListener('blur', function() { const val = parseFloat(this.value); if (!isNaN(val)) { this.value = val.toFixed(2); } }); } // ─── Confirmation Dialog Helper ─── function confirmAction(message, onConfirm, onCancel) { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:10000;display:flex;align-items:center;justify-content:center;'; const dialog = document.createElement('div'); dialog.style.cssText = 'background:var(--bg-secondary);border-radius:12px;padding:24px;max-width:400px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,0.3);'; dialog.innerHTML = `

${escapeHtml(message)}

`; overlay.appendChild(dialog); document.body.appendChild(overlay); document.getElementById('confirm-action-btn').onclick = () => { overlay.remove(); if (onConfirm) onConfirm(); }; overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; } // PDF Download Function async function downloadPDF(entityType, entityId, filename) { try { showToast('PDF wird erstellt...', 'info'); const response = await fetch(`/api/v1/${entityType}/${entityId}/pdf`, { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); if (!response.ok) throw new Error('PDF Erstellung fehlgeschlagen'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${filename}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); showToast('PDF heruntergeladen', 'success'); } catch (e) { showToast('PDF Fehler: ' + e.message, 'error'); } } // Export Functions async function exportZeiterfassung() { const monat = prompt('Monat eingeben (YYYY-MM):', new Date().toISOString().substring(0, 7)); if (!monat) return; try { showToast('Excel wird erstellt...', 'info'); const response = await fetch(`/api/v1/export/zeiterfassung?monat=${monat}`, { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); if (!response.ok) throw new Error('Export fehlgeschlagen'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `Zeiterfassung_${monat}.xlsx`; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); showToast('Excel exportiert', 'success'); } catch (e) { showToast('Export Fehler: ' + e.message, 'error'); } } async function exportIcal() { try { const response = await fetch('/api/v1/export/ical', { headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') } }); if (!response.ok) throw new Error('iCal Export fehlgeschlagen'); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'baugenio_termine.ics'; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); showToast('iCal exportiert', 'success'); } catch (e) { showToast('Export Fehler: ' + e.message, 'error'); } } // Offene Posten Widget async function loadOffenePosten() { try { const data = await apiCall('/api/v1/finanzen/offene-posten'); if (!data) return ''; let html = '

Offene Posten - Fälligkeitsanalyse (Aging)

'; // Summary KPI section html += `
${formatCurrency(data.total_offen)}
Gesamt offen (${data.anzahl_offen} Posten)
`; const buckets = [ { key: 'nicht_faellig', label: 'Nicht fällig', cls: 'success', icon: '✓' }, { key: '0_30_tage', label: '0-30 Tage', cls: 'info', icon: '⚠' }, { key: '31_60_tage', label: '31-60 Tage', cls: 'warning', icon: '⚠' }, { key: '61_90_tage', label: '61-90 Tage', cls: 'danger', icon: '❌' }, { key: 'ueber_90_tage', label: 'Über 90 Tage', cls: 'danger', icon: '❌' } ]; buckets.forEach(b => { const bucket = data.aging[b.key]; if (bucket) { html += `
${formatCurrency(bucket.summe)}
${b.label} (${bucket.anzahl})
`; } }); html += '
'; // Detailed aging table html += '
'; // Aggregate all posten from buckets const allPosten = []; buckets.forEach(b => { const bucket = data.aging[b.key]; if (bucket && bucket.posten) { bucket.posten.forEach(p => { allPosten.push({...p, aging_group: b.key, aging_label: b.label}); }); } }); // Sort by days overdue allPosten.sort((a, b) => (b.tage_ueberfaellig || 0) - (a.tage_ueberfaellig || 0)); // Display each item with conditional styling allPosten.forEach((p, idx) => { const tage = p.tage_ueberfaellig || 0; let rowBg = ''; let tageBg = ''; if (tage >= 14) { rowBg = 'rgba(239, 68, 68, 0.08)'; // red tageBg = '#ef4444'; } else if (tage > 0 && tage < 14) { rowBg = 'rgba(245, 158, 11, 0.08)'; // yellow tageBg = '#f59e0b'; } else { rowBg = ''; tageBg = '#10b981'; } const faellatum = p.faellig_am || '-'; html += ``; }); html += '
NummerEmpfängerBetragFällig amTageAktionen
${escapeHtml(p.nummer || '')} ${escapeHtml(p.empfaenger || '-')} ${formatCurrency(p.betrag || 0)} ${faellatum} ${tage}
'; return html; } catch (e) { console.error('Offene Posten Fehler:', e); return ''; } } function toggleDarkMode() { document.body.classList.toggle('dark-mode'); localStorage.setItem('darkMode', document.body.classList.contains('dark-mode')); updateDarkModeButton(); } function updateDarkModeButton() { const isDark = document.body.classList.contains('dark-mode'); const btn = document.getElementById('settingsDarkModeToggle'); if (btn) btn.textContent = isDark ? 'Deaktivieren' : 'Aktivieren'; } // ═══════════════════════════════════════════ // NOTIFICATION SYSTEM (Runde 12) // ═══════════════════════════════════════════ let notifPanelOpen = false; let wsConnection = null; let wsReconnectAttempts = 0; function toggleNotificationPanel() { const panel = document.getElementById('notificationPanel'); const bell = document.getElementById('notificationBell'); notifPanelOpen = !notifPanelOpen; panel.style.display = notifPanelOpen ? 'block' : 'none'; if (notifPanelOpen) loadNotifications(); // Close panel when clicking outside if (notifPanelOpen) { setTimeout(() => { document.addEventListener('click', closeNotifPanelOnClickOutside); }, 100); } else { document.removeEventListener('click', closeNotifPanelOnClickOutside); } } function closeNotifPanelOnClickOutside(e) { const panel = document.getElementById('notificationPanel'); const bell = document.getElementById('notificationBell'); if (!panel.contains(e.target) && !bell.contains(e.target)) { notifPanelOpen = false; panel.style.display = 'none'; document.removeEventListener('click', closeNotifPanelOnClickOutside); } } async function loadNotifications() { try { const res = await apiCall('/api/v1/benachrichtigungen?limit=20'); if (res && (res.benachrichtigungen || Array.isArray(res))) { const notifications = res.benachrichtigungen || res; updateNotifBadge(notifications.filter(n => !n.is_read).length); const list = document.getElementById('notificationList'); if (!notifications || notifications.length === 0) { list.innerHTML = '
Keine Benachrichtigungen
'; return; } const typeIcons = { task_assigned: '📋', task_due: '⏰', invoice_overdue: '💰', defect_reported: '⚠️', project_status: '🏗️', nachtrag_created: '📝', system_info: 'ℹ️' }; list.innerHTML = notifications.map(n => `
${typeIcons[n.type] || 'ℹ️'}
${escapeHtml(n.titel || n.title || '-')}
${n.message || n.nachricht ? `
${escapeHtml(n.message || n.nachricht)}
` : ''}
${formatTimeAgo(n.created_at || '')}
`).join(''); } } catch (e) { console.error('Notifications laden fehlgeschlagen:', e); } } function updateNotifBadge(count) { const badge = document.getElementById('notifBadge'); if (badge) { badge.textContent = count > 99 ? '99+' : count; badge.style.display = count > 0 ? 'block' : 'none'; } } async function handleNotifClick(id, link) { if (id) { try { await apiCall('/benachrichtigungen/' + id, 'PATCH', { is_read: true }); } catch(e) {} } if (link) { notifPanelOpen = false; document.getElementById('notificationPanel').style.display = 'none'; goToPage(link); } loadNotifications(); } async function markAllNotificationsRead() { try { await apiCall('/benachrichtigungen/mark-all-read', 'POST'); loadNotifications(); showToast('Alle als gelesen markiert', 'success'); } catch (e) { showToast('Fehler beim Markieren', 'error'); } } async function clearAllNotifications() { if (!confirm('Alle Benachrichtigungen löschen?')) return; try { await apiCall('/benachrichtigungen/clear', 'DELETE'); loadNotifications(); showToast('Benachrichtigungen gelöscht', 'success'); } catch(e) { showToast('Fehler beim Löschen', 'error'); } } function formatTimeAgo(dateStr) { if (!dateStr) return 'Unbekannt'; const date = new Date(dateStr); const now = new Date(); const diff = Math.floor((now - date) / 1000); if (diff < 60) return 'Gerade eben'; if (diff < 3600) return Math.floor(diff / 60) + ' Min.'; if (diff < 86400) return Math.floor(diff / 3600) + ' Std.'; if (diff < 604800) return Math.floor(diff / 86400) + ' Tage'; return formatDate(dateStr); } // WebSocket Client function initWebSocket() { const token = localStorage.getItem('token') || sessionStorage.getItem('token'); if (!token) return; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${location.host}/ws?token=${token}`; try { wsConnection = new WebSocket(wsUrl); wsConnection.onopen = () => { console.log('[WS] Verbunden'); wsReconnectAttempts = 0; updateConnectionStatus(true); }; wsConnection.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'pong') return; if (data.event === 'notification') { updateNotifBadge((parseInt(document.getElementById('notifBadge')?.textContent) || 0) + 1); showToast(toObj(data).title, 'info'); if (notifPanelOpen) loadNotifications(); } } catch (e) {} }; wsConnection.onclose = () => { updateConnectionStatus(false); const delay = Math.min(1000 * Math.pow(2, wsReconnectAttempts), 30000); wsReconnectAttempts++; setTimeout(initWebSocket, delay); }; wsConnection.onerror = () => { updateConnectionStatus(false); }; // Heartbeat setInterval(() => { if (wsConnection && wsConnection.readyState === WebSocket.OPEN) { wsConnection.send(JSON.stringify({ type: 'ping' })); } }, 30000); } catch (e) { console.error('[WS] Fehler:', e); } } function updateConnectionStatus(online) { let dot = document.getElementById('wsStatusDot'); if (!dot) { dot = document.createElement('span'); dot.id = 'wsStatusDot'; dot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;margin-left:8px;vertical-align:middle;transition:background 0.3s;'; const bell = document.getElementById('notificationBell'); if (bell) bell.parentNode.insertBefore(dot, bell.nextSibling); } dot.style.background = online ? '#2ecc71' : '#e74c3c'; dot.title = online ? 'WebSocket verbunden' : 'WebSocket getrennt'; } // ===== SETTINGS FUNCTIONS ===== function loadSettings() { if (!currentUser) return; // Show admin tab if user is admin const isAdmin = currentUser.role === 'admin' || currentUser.role === 'super_admin'; const adminTab = document.getElementById('settingsTabUsers'); if (adminTab) adminTab.style.display = isAdmin ? '' : 'none'; // Load user profile const nameParts = (currentUser.fullName || '').split(' '); const el = (id) => document.getElementById(id); if (el('settingsFirstName')) el('settingsFirstName').value = nameParts[0] || ''; if (el('settingsLastName')) el('settingsLastName').value = nameParts.slice(1).join(' ') || ''; if (el('settingsEmailInput')) el('settingsEmailInput').value = currentUser.email || ''; if (el('settingsPositionInput')) el('settingsPositionInput').value = currentUser.position || ''; if (el('settingsPhoneInput')) el('settingsPhoneInput').value = currentUser.phone || ''; // Profile avatar const initials = ((nameParts[0] || 'A')[0] + (nameParts[1] || '')[0] || '').toUpperCase(); if (el('settingsProfileAvatar')) el('settingsProfileAvatar').textContent = initials || 'A'; if (el('settingsProfileName')) el('settingsProfileName').textContent = currentUser.fullName || 'Benutzer'; if (el('settingsProfileRole')) el('settingsProfileRole').textContent = currentUser.role || 'Mitarbeiter'; // Session info if (el('settingsLastLogin')) el('settingsLastLogin').textContent = currentUser.last_login ? formatDate(currentUser.last_login) : 'Aktuell'; try { const token = localStorage.getItem('token'); if (token && el('settingsTokenExpiry')) { const payload = JSON.parse(atob(token.split('.')[1])); el('settingsTokenExpiry').textContent = payload.exp ? new Date(payload.exp * 1000).toLocaleString('de-DE') : '-'; } } catch(e) {} // Load company info from localStorage const firmendaten = JSON.parse(localStorage.getItem('firmendaten') || '{}'); if (el('settingFirmaName')) el('settingFirmaName').value = firmendaten.name || 'energuru GmbH'; if (el('settingFirmaPhone')) el('settingFirmaPhone').value = firmendaten.phone || ''; if (el('settingFirmaAddress')) el('settingFirmaAddress').value = firmendaten.address || ''; if (el('settingFirmaEmail')) el('settingFirmaEmail').value = firmendaten.email || ''; if (el('settingFirmaPlz')) el('settingFirmaPlz').value = firmendaten.plz || ''; if (el('settingFirmaVat')) el('settingFirmaVat').value = firmendaten.vat || ''; if (el('settingFirmaCeo')) el('settingFirmaCeo').value = firmendaten.ceo || ''; if (el('settingFirmaRegistry')) el('settingFirmaRegistry').value = firmendaten.registry || ''; if (el('settingFirmaIban')) el('settingFirmaIban').value = firmendaten.iban || ''; if (el('settingFirmaBic')) el('settingFirmaBic').value = firmendaten.bic || ''; if (el('settingFirmaBankName')) el('settingFirmaBankName').value = firmendaten.bank_name || ''; // Load preferences const benutzerEinstellungen = JSON.parse(localStorage.getItem('benutzerEinstellungen') || '{}'); if (el('settingLanguage')) el('settingLanguage').value = benutzerEinstellungen.sprache || 'de'; if (el('settingTimezone')) el('settingTimezone').value = benutzerEinstellungen.zeitzone || 'Europe/Berlin'; if (el('settingDateFormat')) el('settingDateFormat').value = benutzerEinstellungen.datumsformat || 'DD.MM.YYYY'; if (el('settingDarkMode')) el('settingDarkMode').checked = !!benutzerEinstellungen.darkMode; // Load API keys const apiKeys = JSON.parse(localStorage.getItem('apiKeys') || '{}'); if (el('settingsOpenaiApiKey')) el('settingsOpenaiApiKey').value = apiKeys.openai || ''; if (el('settingsGeminiApiKey')) el('settingsGeminiApiKey').value = apiKeys.gemini || ''; if (el('settingsGrokApiKey')) el('settingsGrokApiKey').value = apiKeys.grok || ''; if (el('settingsClaudeApiKey')) el('settingsClaudeApiKey').value = apiKeys.claude || ''; if (el('settingsPerplexityApiKey')) el('settingsPerplexityApiKey').value = apiKeys.perplexity || ''; if (el('settingsAiModelSelect')) el('settingsAiModelSelect').value = apiKeys.model || 'claude'; // Lade Benachrichtigungs-Einstellungen vom Server loadNotificationPreferences(); } async function saveUserProfile() { const firstName = (document.getElementById('settingsFirstName')?.value || '').trim(); const lastName = (document.getElementById('settingsLastName')?.value || '').trim(); const position = (document.getElementById('settingsPositionInput')?.value || '').trim(); const phone = (document.getElementById('settingsPhoneInput')?.value || '').trim(); if (!firstName) { showToast('Vorname ist erforderlich', 'error'); return; } try { await apiCall('/user/profile', 'PATCH', { first_name: firstName, last_name: lastName, position, phone }); currentUser.fullName = (firstName + ' ' + lastName).trim(); currentUser.position = position; currentUser.phone = phone; // Update UI const el = (id) => document.getElementById(id); if (el('settingsProfileName')) el('settingsProfileName').textContent = currentUser.fullName; if (el('profileDropdownName')) el('profileDropdownName').textContent = currentUser.fullName; const initials = (firstName[0] + (lastName[0] || '')).toUpperCase(); if (el('settingsProfileAvatar')) el('settingsProfileAvatar').textContent = initials; showToast('Profil erfolgreich gespeichert', 'success'); } catch (e) { showToast('Fehler beim Speichern des Profils', 'error'); } } async function saveFirmendaten() { const firmendaten = { name: document.getElementById('settingFirmaName').value.trim(), phone: document.getElementById('settingFirmaPhone').value.trim(), address: document.getElementById('settingFirmaAddress').value.trim(), email: document.getElementById('settingFirmaEmail').value.trim(), plz: document.getElementById('settingFirmaPlz').value.trim(), vat: document.getElementById('settingFirmaVat').value.trim(), ceo: document.getElementById('settingFirmaCeo').value.trim(), registry: (document.getElementById('settingFirmaRegistry')?.value || '').trim(), iban: (document.getElementById('settingFirmaIban')?.value || '').trim(), bic: (document.getElementById('settingFirmaBic')?.value || '').trim(), bank_name: (document.getElementById('settingFirmaBankName')?.value || '').trim() }; if (!firmendaten.name) { showToast('Firmenname ist erforderlich', 'error'); return; } localStorage.setItem('firmendaten', JSON.stringify(firmendaten)); // Sync to server for PDF generation try { await apiCall('/settings', 'PATCH', { firma_name: firmendaten.name, firma_phone: firmendaten.phone, firma_address: firmendaten.address, firma_email: firmendaten.email, firma_plz: firmendaten.plz, firma_vat: firmendaten.vat, firma_ceo: firmendaten.ceo, firma_registry: firmendaten.registry, firma_iban: firmendaten.iban || '', firma_bic: firmendaten.bic || '', firma_bank_name: firmendaten.bank_name || '' }); showToast('Firmendaten gespeichert & synchronisiert', 'success'); } catch (e) { showToast('Lokal gespeichert (Server-Sync fehlgeschlagen)', 'warning'); } } function saveBenutzereinstellen() { const benutzerEinstellungen = { sprache: document.getElementById('settingLanguage').value, zeitzone: document.getElementById('settingTimezone').value, datumsformat: document.getElementById('settingDateFormat').value, darkMode: document.getElementById('settingDarkMode').checked, emailNotif: document.getElementById('settingEmailNotif').checked, pushNotif: document.getElementById('settingPushNotif').checked }; localStorage.setItem('benutzerEinstellungen', JSON.stringify(benutzerEinstellungen)); showToast('Benutzer-Einstellungen gespeichert', 'success'); if (benutzerEinstellungen.darkMode) { document.documentElement.style.colorScheme = 'dark'; } else { document.documentElement.style.colorScheme = 'light'; } } // ─── Passwort aendern (Runde 13) ─── async function changePassword() { const oldPw = document.getElementById('settingOldPassword').value; const newPw = document.getElementById('settingNewPassword').value; const confirmPw = document.getElementById('settingConfirmPassword').value; if (!oldPw || !newPw) return showToast('Bitte alle Felder ausfüllen', 'error'); if (newPw.length < 8) return showToast('Neues Passwort muss mind. 8 Zeichen haben', 'error'); if (newPw !== confirmPw) return showToast('Passwörter stimmen nicht überein', 'error'); try { await apiCall('/auth/change-password', 'POST', {old_password: oldPw, new_password: newPw}); showToast('Passwort erfolgreich geändert', 'success'); document.getElementById('settingOldPassword').value = ''; document.getElementById('settingNewPassword').value = ''; document.getElementById('settingConfirmPassword').value = ''; } catch(e) { showToast(e.message || 'Fehler beim Passwort-Ändern', 'error'); } } // ─── 2FA Functions ─── async function loadTwoFactorStatus() { try { const res = await apiCall('/auth/2fa/status'); const statusEl = document.getElementById('twoFactorStatusText'); const btnEnable = document.getElementById('enableTwoFactorBtn'); const btnDisable = document.getElementById('disableTwoFactorBtn'); const backupEl = document.getElementById('twoFactorBackupCodes'); if (res.enabled) { statusEl.textContent = '✓ Aktiviert'; statusEl.style.color = 'var(--color-green)'; btnEnable.style.display = 'none'; btnDisable.style.display = 'inline-block'; backupEl.textContent = `Verfügbare Backup-Codes: ${res.backup_codes_remaining || 0}`; } else { statusEl.textContent = '✗ Deaktiviert'; statusEl.style.color = 'var(--color-orange)'; btnEnable.style.display = 'inline-block'; btnDisable.style.display = 'none'; backupEl.textContent = 'Backup-Codes zur Wiederherstellung verfügbar'; } } catch(e) { console.error('2FA status load error:', e); document.getElementById('twoFactorStatusText').textContent = 'Status konnte nicht geladen werden'; } } async function initTwoFactorSetup() { try { const res = await apiCall('/auth/2fa/setup'); // Store setup data window._twoFactorSetup = { secret: res.setup_secret, backupCodes: res.backup_codes }; // Show QR code const qrcodeContainer = document.getElementById('qrcodeContainer'); const qrcodeImage = document.getElementById('qrcodeImage'); const totpSecret = document.getElementById('totpSecret'); qrcodeImage.innerHTML = ``; totpSecret.value = res.setup_secret; qrcodeContainer.style.display = 'block'; // Prompt user to enter code const code = prompt('Geben Sie einen 6-stelligen Code aus Ihrer Authentifizierungs-App ein:'); if (!code) { qrcodeContainer.style.display = 'none'; return; } // Verify code await apiCall('/auth/2fa/verify', 'POST', { code: code.trim(), setup_secret: res.setup_secret, backup_codes: res.backup_codes }); showToast('2FA erfolgreich aktiviert! Backup-Codes speichern Sie sicher.', 'success'); qrcodeContainer.style.display = 'none'; loadTwoFactorStatus(); } catch(e) { showToast(`2FA-Setup-Fehler: ${e.message}`, 'error'); document.getElementById('qrcodeContainer').style.display = 'none'; } } async function disableTwoFactor() { const password = prompt('Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren:'); if (!password) return; try { await apiCall('/auth/2fa/disable', 'POST', { password }); showToast('2FA erfolgreich deaktiviert', 'success'); loadTwoFactorStatus(); } catch(e) { showToast(`Fehler beim Deaktivieren: ${e.message}`, 'error'); } } // ─── Benachrichtigungs-Einstellungen (Server-Sync) ─── async function loadNotificationPreferences() { try { const res = await apiCall('/notifications/preferences'); const prefs = res.preferences || res; if (prefs) { const el = (id) => document.getElementById(id); if (el('settingEmailNotif')) el('settingEmailNotif').checked = prefs.email_enabled !== 0; if (el('settingPushNotif')) el('settingPushNotif').checked = prefs.push_enabled !== 0; if (el('notifTaskAssigned')) el('notifTaskAssigned').checked = prefs.task_assigned !== 0; if (el('notifTaskCompleted')) el('notifTaskCompleted').checked = prefs.task_completed !== 0; if (el('notifDefectReported')) el('notifDefectReported').checked = prefs.defect_reported !== 0; if (el('notifInvoiceOverdue')) el('notifInvoiceOverdue').checked = prefs.invoice_overdue !== 0; if (el('notifProjectUpdate')) el('notifProjectUpdate').checked = prefs.project_update !== 0; if (el('notifSystemInfo')) el('notifSystemInfo').checked = prefs.system_info !== 0; if (prefs.quiet_hours_start && el('notifQuietStart')) el('notifQuietStart').value = prefs.quiet_hours_start; if (prefs.quiet_hours_end && el('notifQuietEnd')) el('notifQuietEnd').value = prefs.quiet_hours_end; } } catch(e) { /* Defaults beibehalten */ } } async function saveNotificationPreferences() { try { await apiCall('/notifications/preferences', 'PATCH', { email_enabled: document.getElementById('settingEmailNotif').checked ? 1 : 0, push_enabled: document.getElementById('settingPushNotif').checked ? 1 : 0, task_assigned: document.getElementById('notifTaskAssigned').checked ? 1 : 0, task_completed: document.getElementById('notifTaskCompleted').checked ? 1 : 0, defect_reported: document.getElementById('notifDefectReported').checked ? 1 : 0, invoice_overdue: document.getElementById('notifInvoiceOverdue').checked ? 1 : 0, project_update: document.getElementById('notifProjectUpdate').checked ? 1 : 0, system_info: document.getElementById('notifSystemInfo').checked ? 1 : 0, quiet_hours_start: document.getElementById('notifQuietStart').value || null, quiet_hours_end: document.getElementById('notifQuietEnd').value || null }); showToast('Benachrichtigungs-Einstellungen gespeichert', 'success'); } catch(e) { showToast('Fehler beim Speichern der Benachrichtigungen', 'error'); } } // ─── Settings Tab Navigation ─── function switchSettingsTab(tabName) { document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.settings-section').forEach(s => s.classList.remove('active')); const tab = document.querySelector(`.settings-tab[onclick*="'${tabName}'"]`); const section = document.getElementById('settingsTab-' + tabName); if (tab) tab.classList.add('active'); if (section) section.classList.add('active'); // Load data for specific tabs if (tabName === 'benutzerverwaltung') loadUserManagement(); if (tabName === 'webhooks') loadWebhooksList(); if (tabName === 'sicherheit') loadTwoFactorStatus(); if (tabName === 'datensicherung') loadBackupStatus(); if (tabName === 'benachrichtigungen') renderNotificationSettings(); if (tabName === 'ki-api') loadKiModelStatus(); } // ─── Database Backups (Admin) ─── async function loadBackupStatus() { try { const res = await apiCall('/admin/backup/status'); const lastBackupEl = document.getElementById('lastBackupTime'); const dbSizeEl = document.getElementById('backupDBSize'); const backupCountEl = document.getElementById('backupCount'); if (lastBackupEl && res.latest_backup) { const date = new Date(res.latest_backup + 'Z'); lastBackupEl.textContent = date.toLocaleString('de-DE'); } else if (lastBackupEl) { lastBackupEl.textContent = 'Keine Sicherung vorhanden'; } if (dbSizeEl) { dbSizeEl.textContent = formatFileSize(res.database_size || 0); } if (backupCountEl) { backupCountEl.textContent = (res.backup_count || 0) + ' Sicherungen'; } await loadBackupList(); } catch(e) { showToast(`Fehler beim Laden des Sicherungsstatus: ${e.message}`, 'error'); } } async function loadBackupList() { try { const res = await apiCall('/admin/backups'); const backups = res.backups || []; const container = document.getElementById('backupListContainer'); if (!container) return; if (!backups.length) { container.innerHTML = '
Keine Sicherungen vorhanden
'; return; } container.innerHTML = backups.map(b => { const date = new Date(b.timestamp + 'Z'); return `
${escapeHtml(b.filename)}
${date.toLocaleString('de-DE')} | ${formatFileSize(b.size || 0)} | ${b.table_count || 0} Tabellen
`; }).join(''); } catch(e) { showToast(`Fehler beim Laden der Sicherungsliste: ${e.message}`, 'error'); } } async function createBackup() { try { const btn = event?.target; if (btn) btn.disabled = true; showToast('Sicherung wird erstellt...', 'info'); const res = await apiCall('/admin/backups', 'POST', {action: 'create'}); showToast(`Sicherung erfolgreich erstellt: ${res.filename}`, 'success'); await loadBackupStatus(); } catch(e) { showToast(`Fehler beim Erstellen der Sicherung: ${e.message}`, 'error'); } finally { if (btn) btn.disabled = false; } } function showRestoreModal(filename) { const modal = document.getElementById('restoreConfirmModal'); const filenameEl = document.getElementById('restoreBackupFilename'); if (modal && filenameEl) { filenameEl.textContent = escapeHtml(filename); filenameEl.dataset.filename = filename; modal.classList.add('active'); } } function closeRestoreModal() { const modal = document.getElementById('restoreConfirmModal'); if (modal) modal.classList.remove('active'); } async function confirmRestore() { try { const filenameEl = document.getElementById('restoreBackupFilename'); if (!filenameEl || !filenameEl.dataset.filename) { showToast('Sicherungsdatei nicht gefunden', 'error'); return; } closeRestoreModal(); showToast('Sicherung wird wiederhergestellt...', 'info'); const res = await apiCall('/admin/backups', 'POST', { action: 'restore', filename: filenameEl.dataset.filename }); showToast('Sicherung erfolgreich wiederhergestellt', 'success'); await loadBackupStatus(); } catch(e) { showToast(`Fehler beim Wiederherstellen: ${e.message}`, 'error'); } } async function confirmDeleteBackup(filename) { if (!confirm(`Sind Sie sicher, dass Sie die Sicherung "${filename}" löschen möchten?`)) return; try { showToast('Sicherung wird gelöscht...', 'info'); const res = await apiCall('/admin/backups', 'POST', { action: 'delete', filename: filename }); showToast('Sicherung erfolgreich gelöscht', 'success'); await loadBackupStatus(); } catch(e) { showToast(`Fehler beim Löschen der Sicherung: ${e.message}`, 'error'); } } // ─── User Management (Admin) ─── let userMgmtPage = 1; async function loadUserManagement() { const search = (document.getElementById('userMgmtSearch')?.value || '').trim(); const role = document.getElementById('userMgmtRoleFilter')?.value || ''; const status = document.getElementById('userMgmtStatusFilter')?.value || ''; const container = document.getElementById('userMgmtList'); if (!container) return; try { const params = new URLSearchParams({page: userMgmtPage, per_page: 20}); if (search) params.set('search', search); if (role) params.set('role', role); if (status) params.set('status', status); const res = await apiCall('/users?' + params.toString()); const users = res.users || []; if (!users.length) { container.innerHTML = '
Keine Benutzer gefunden
'; return; } const roleColors = {admin:'#ef4444',super_admin:'#ef4444',projektleiter:'#3b82f6',bauleiter:'#f59e0b',mitarbeiter:'#6b7280'}; const roleBg = {admin:'rgba(239,68,68,0.15)',super_admin:'rgba(239,68,68,0.15)',projektleiter:'rgba(59,130,246,0.15)',bauleiter:'rgba(245,158,11,0.15)',mitarbeiter:'rgba(107,114,128,0.15)'}; container.innerHTML = users.map(u => { const name = ((u.first_name||'') + ' ' + (u.last_name||'')).trim() || u.email; const initials = ((u.first_name||'?')[0] + (u.last_name||'')[0] || '').toUpperCase(); const isActive = u.is_active !== 0; return `
${escapeHtml(initials)}
${escapeHtml(name)}
${escapeHtml(u.email||'')}
${u.position ? `${escapeHtml(u.position)}` : ''} ${escapeHtml(u.role||'mitarbeiter')}
`; }).join(''); // Pagination const pagination = document.getElementById('userMgmtPagination'); if (pagination && res.total_pages > 1) { let btns = ''; for (let p = 1; p <= res.total_pages; p++) { btns += ``; } pagination.innerHTML = btns; } else if (pagination) pagination.innerHTML = ''; } catch(e) { container.innerHTML = '
Fehler beim Laden der Benutzer
'; } } function showCreateUserModal() { document.getElementById('userModalTitle').textContent = 'Neuer Benutzer'; document.getElementById('userModalSaveBtn').textContent = 'Benutzer erstellen'; document.getElementById('userModalId').value = ''; document.getElementById('userModalFirstName').value = ''; document.getElementById('userModalLastName').value = ''; document.getElementById('userModalEmail').value = ''; document.getElementById('userModalEmail').readOnly = false; document.getElementById('userModalRole').value = 'mitarbeiter'; document.getElementById('userModalPosition').value = ''; document.getElementById('userModalPassword').value = ''; document.getElementById('userModalPhone').value = ''; document.getElementById('userModalPasswordGroup').style.display = ''; openModal('userModal'); } async function editUserModal(userId) { try { const res = await apiCall('/user-management/' + userId); document.getElementById('userModalTitle').textContent = 'Benutzer bearbeiten'; document.getElementById('userModalSaveBtn').textContent = 'Speichern'; document.getElementById('userModalId').value = res.id; document.getElementById('userModalFirstName').value = res.first_name || ''; document.getElementById('userModalLastName').value = res.last_name || ''; document.getElementById('userModalEmail').value = res.email || ''; document.getElementById('userModalEmail').readOnly = true; document.getElementById('userModalRole').value = res.role || 'mitarbeiter'; document.getElementById('userModalPosition').value = res.position || ''; document.getElementById('userModalPhone').value = res.phone || ''; document.getElementById('userModalPassword').value = ''; document.getElementById('userModalPasswordGroup').style.display = 'none'; openModal('userModal'); } catch(e) { showToast('Fehler beim Laden des Benutzers', 'error'); } } async function saveUserModal() { const id = document.getElementById('userModalId').value; const firstName = document.getElementById('userModalFirstName').value.trim(); const lastName = document.getElementById('userModalLastName').value.trim(); const email = document.getElementById('userModalEmail').value.trim(); const role = document.getElementById('userModalRole').value; const position = document.getElementById('userModalPosition').value.trim(); const phone = document.getElementById('userModalPhone').value.trim(); const password = document.getElementById('userModalPassword').value; if (!firstName || !lastName || !email) { showToast('Vorname, Nachname und E-Mail sind erforderlich', 'error'); return; } try { if (id) { // Edit existing user const body = {first_name: firstName, last_name: lastName, role, position, phone}; if (password) body.password = password; await apiCall('/user-management/' + id, 'PATCH', body); showToast('Benutzer aktualisiert', 'success'); } else { // Create new user if (!password || password.length < 8) { showToast('Passwort muss mind. 8 Zeichen haben', 'error'); return; } await apiCall('/users/create', 'POST', { first_name: firstName, last_name: lastName, email, role, position, phone, password }); showToast('Benutzer erstellt', 'success'); } closeModal('userModal'); loadUserManagement(); } catch(e) { showToast(e.message || 'Fehler beim Speichern', 'error'); } } // ─── System Health & Audit Log ─── async function loadSystemHealth() { const container = document.getElementById('systemHealthResult'); if (!container) return; container.style.display = 'block'; container.innerHTML = '
Prüfe System...
'; try { const res = await apiCall('/health'); container.innerHTML = `
Status
${escapeHtml(res.status||'unknown')}
Tabellen
${res.tables||'-'}
Indexe
${res.indexes||'-'}
`; } catch(e) { container.innerHTML = '
Fehler: ' + escapeHtml(e.message||'Unbekannt') + '
'; } } async function loadAuditLog() { const container = document.getElementById('auditLogResult'); if (!container) return; container.style.display = 'block'; container.innerHTML = '
Lade Audit-Log...
'; try { const res = await apiCall('/audit-log'); const entries = Array.isArray(res) ? res : (res.entries || res.logs || []); if (!Array.isArray(entries) || !entries.length) { container.innerHTML = '
Keine Audit-Einträge vorhanden
'; return; } container.innerHTML = '' + entries.slice(0, 50).map(e => ``).join('') + '
ZeitAktionTypDetails
${escapeHtml(e.created_at||e.timestamp||'-')}${escapeHtml(e.action||'-')}${escapeHtml(e.entity_type||e.type||'-')}${escapeHtml(e.details||e.description||'-')}
'; } catch(e) { container.innerHTML = '
Fehler: ' + escapeHtml(e.message||'Unbekannt') + '
'; } } // ─── Dokumente filtern ─── function filterDokumente() { const search = (document.getElementById('dokSearchInput')?.value || '').toLowerCase(); const typ = document.getElementById('dokFilterType')?.value || ''; const pid = document.getElementById('dokFilterProject')?.value || ''; let filtered = allDokumente; // Filter by current folder if (currentDmsFolder) { filtered = filtered.filter(d => d.folder_id === currentDmsFolder); } if (search) filtered = filtered.filter(d => (d.name||'').toLowerCase().includes(search) || (d.tags||'').toLowerCase().includes(search) || (d.beschreibung||'').toLowerCase().includes(search)); if (typ) { if (typ === 'jpg') filtered = filtered.filter(d => ['jpg','jpeg','png','gif','bmp','webp'].includes((d.typ||'').toLowerCase())); else filtered = filtered.filter(d => (d.typ||'').toLowerCase() === typ); } if (pid) filtered = filtered.filter(d => d.project_id === pid); renderDokumente(filtered); } // ─── Drag & Drop Upload (Runde 15) ─── async function handleDocDrop(event) { event.preventDefault(); const dropZone = document.getElementById('dokDropZone'); dropZone.style.borderColor = 'var(--border-color)'; dropZone.style.background = 'transparent'; const files = event.dataTransfer.files; if (files.length) await uploadDroppedFiles(files); } async function uploadDroppedFiles(files) { if (!files || !files.length) return; const formData = new FormData(); for (let i = 0; i < files.length; i++) formData.append('file', files[i]); try { showToast(`${files.length} Datei(en) werden hochgeladen...`, 'info'); const token = localStorage.getItem('token'); const res = await fetch('/api/v1/upload', {method: 'POST', body: formData, headers: {'Authorization': `Bearer ${token}`}}); const data = await res.json(); if (res.ok) { showToast(`${files.length} Datei(en) erfolgreich hochgeladen`, 'success'); loadDokumente(); } else { showToast(data.error || 'Upload fehlgeschlagen', 'error'); } } catch(e) { showToast('Upload fehlgeschlagen', 'error'); } } // ─── Dashboard Export (Runde 14) ─── async function exportDashboard(format) { try { const token = localStorage.getItem('token'); const res = await fetch(`/api/v1/dashboard/export?format=${format}`, {headers: {'Authorization': `Bearer ${token}`}}); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `baugenio_dashboard_${new Date().toISOString().split('T')[0]}.${format === 'csv' ? 'csv' : 'json'}`; a.click(); URL.revokeObjectURL(url); showToast('Dashboard-Export heruntergeladen', 'success'); } catch(e) { showToast('Export fehlgeschlagen', 'error'); } } async function testSmtpConnection() { showToast('Test-E-Mail wird gesendet...', 'info'); try { const smtpConfig = JSON.parse(localStorage.getItem('smtpConfig') || '{}'); if (!smtpConfig.server) { showToast('Bitte SMTP-Konfiguration speichern', 'error'); return; } const response = await apiCall('/smtp/test', 'POST', smtpConfig); showToast('Test-E-Mail erfolgreich gesendet', 'success'); } catch(e) { showToast('SMTP-Verbindung fehlgeschlagen', 'error'); } } function clearCache() { try { localStorage.clear(); sessionStorage.clear(); if (window.caches) { caches.keys().then(cacheNames => { cacheNames.forEach(cacheName => caches.delete(cacheName)); }); } showToast('Cache gelöscht und Seite wird neu geladen', 'success'); setTimeout(() => location.reload(), 1000); } catch(e) { showToast('Fehler beim Leeren des Cache', 'error'); } } async function saveCompanyInfo() { const companyInfo = { name: document.getElementById('settingsCompanyNameInput').value.trim(), address: document.getElementById('settingsAddressInput').value.trim(), vat: document.getElementById('settingsVatInput').value.trim(), registry: document.getElementById('settingsRegistryInput').value.trim() }; if (!companyInfo.name) { showToast('Firmenname ist erforderlich', 'error'); return; } try { await apiCall('/settings', 'PATCH', { company_name: companyInfo.name, company_address: companyInfo.address, company_vat: companyInfo.vat, company_registry: companyInfo.registry }); localStorage.setItem('companyInfo', JSON.stringify(companyInfo)); showToast('Firmeninformationen gespeichert', 'success'); } catch(e) { localStorage.setItem('companyInfo', JSON.stringify(companyInfo)); showToast('Lokal gespeichert (Server nicht erreichbar)', 'warning'); } } async function saveApiKeys() { const apiKeys = { openai: document.getElementById('settingsOpenaiApiKey').value.trim(), gemini: document.getElementById('settingsGeminiApiKey').value.trim(), grok: document.getElementById('settingsGrokApiKey').value.trim(), claude: document.getElementById('settingsClaudeApiKey').value.trim(), perplexity: document.getElementById('settingsPerplexityApiKey').value.trim() }; try { await apiCall('/settings', 'PATCH', { api_key_openai: apiKeys.openai, api_key_gemini: apiKeys.gemini, api_key_grok: apiKeys.grok, api_key_claude: apiKeys.claude, api_key_perplexity: apiKeys.perplexity }); localStorage.setItem('apiKeys', JSON.stringify(apiKeys)); showToast('API-Keys gespeichert', 'success'); loadKiModelStatus(); // Refresh status after save } catch(e) { localStorage.setItem('apiKeys', JSON.stringify(apiKeys)); showToast('Lokal gespeichert (Server nicht erreichbar)', 'warning'); } } async function loadKiModelStatus() { try { const result = await apiCall('/ki/models'); const panel = document.getElementById('kiModelStatusPanel'); const list = document.getElementById('kiModelStatusList'); if (!panel || !list) return; const modelLabels = { chatgpt: { name: 'ChatGPT (GPT-4o)', icon: '🟢' }, gemini: { name: 'Gemini 2.0 Flash', icon: '🔵' }, grok: { name: 'Grok-3', icon: '🟠' }, claude: { name: 'Claude Sonnet 4', icon: '🟣' }, perplexity: { name: 'Perplexity Sonar Pro', icon: '🔴' } }; const sourceLabels = { env: 'Server-Umgebung', settings: 'Einstellungen', none: 'Nicht konfiguriert' }; let html = ''; const models = result.models || {}; for (const [key, info] of Object.entries(models)) { const label = modelLabels[key] || { name: key, icon: '⚪' }; const configured = info.source && info.source !== 'none'; const statusColor = configured ? 'var(--color-success, #22c55e)' : 'var(--text-tertiary)'; const statusDot = configured ? '●' : '○'; const sourceText = sourceLabels[info.source] || info.source || 'Unbekannt'; html += `
${statusDot} ${label.name}
${sourceText}
`; } list.innerHTML = html; panel.style.display = 'block'; // Summary const configuredCount = Object.values(models).filter(m => m.source && m.source !== 'none').length; const total = Object.keys(models).length; const summaryEl = panel.querySelector('.ki-model-summary'); if (!summaryEl) { const s = document.createElement('div'); s.className = 'ki-model-summary'; s.style.cssText = 'margin-top:12px;font-size:12px;color:var(--text-tertiary);text-align:center;'; s.textContent = `${configuredCount} von ${total} Modellen konfiguriert`; panel.appendChild(s); } else { summaryEl.textContent = `${configuredCount} von ${total} Modellen konfiguriert`; } } catch(e) { /* Silently fail if endpoint not available */ } } function toggleApiKeyVisibility(inputId) { const input = document.getElementById(inputId); const button = event.target; if (input.type === 'password') { input.type = 'text'; button.textContent = 'Verbergen'; } else { input.type = 'password'; button.textContent = 'Zeigen'; } } function exportAllData() { try { const exportData = { user: currentUser, preferences: JSON.parse(localStorage.getItem('preferences') || '{}'), companyInfo: JSON.parse(localStorage.getItem('companyInfo') || '{}'), projects: localStorage.getItem('projects') ? JSON.parse(localStorage.getItem('projects')) : [], timestamp: new Date().toISOString() }; const dataStr = JSON.stringify(exportData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(dataBlob); const link = document.createElement('a'); link.href = url; link.download = `energuru_export_${new Date().getTime()}.json`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); showToast('Daten erfolgreich exportiert', 'success'); } catch (e) { showToast('Fehler beim Exportieren der Daten', 'error'); } } // ===== KALENDER ===== let kalenderTermine = []; let kalenderProjekte = []; let currentCalendarMonth = new Date(); let kalenderView = 'monat'; // monat | woche | tag const germanMonths = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; const germanDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; const germanDaysFull = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']; const terminColors = {'termin':'#22B8CF','besprechung':'#3b82f6','frist':'#ef4444','abnahme':'#10b981','wartung':'#f59e0b','deadline':'#ef4444'}; function getFilteredTermine() { const typFilter = document.getElementById('kalenderFilterTyp')?.value || ''; const projFilter = document.getElementById('kalenderFilterProjekt')?.value || ''; return kalenderTermine.filter(t => { if (typFilter && (t.typ || '') !== typFilter) return false; if (projFilter && (t.projekt_id || '') !== projFilter) return false; return true; }); } function getTermineForDate(dateStr, filtered) { return filtered.filter(t => t.datum === dateStr); } function dateToDatumStr(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function getMondayOfWeek(d) { const date = new Date(d); const day = date.getDay(); const diff = day === 0 ? -6 : 1 - day; date.setDate(date.getDate() + diff); date.setHours(0,0,0,0); return date; } async function loadKalender() { try { // Calculate date range for the current view let startDate, endDate; if (kalenderView === 'monat') { startDate = new Date(currentCalendarMonth); startDate.setDate(1); endDate = new Date(currentCalendarMonth); endDate.setMonth(endDate.getMonth() + 1); endDate.setDate(0); } else if (kalenderView === 'woche') { const monday = getMondayOfWeek(currentCalendarMonth); startDate = new Date(monday); endDate = new Date(monday); endDate.setDate(endDate.getDate() + 6); } else { startDate = new Date(currentCalendarMonth); endDate = new Date(currentCalendarMonth); } const startDateStr = dateToDatumStr(startDate); const endDateStr = dateToDatumStr(endDate); const [termineRes, aufgabenRes, projRes] = await Promise.all([ apiCall(`/termine?start_date=${startDateStr}&end_date=${endDateStr}`), apiCall('/aufgaben').catch(() => []), apiCall('/projects').catch(() => []) ]); const termineArr = Array.isArray(termineRes) ? termineRes : (termineRes?.data || []); const aufgabenArr = Array.isArray(aufgabenRes) ? aufgabenRes : (aufgabenRes?.data || []); const projArr = Array.isArray(projRes) ? projRes : (projRes?.data || []); kalenderTermine = termineArr.map(t => ({ ...t, typ: t.typ || 'termin', _source: 'termin' })); // Add aufgaben with due_date as deadline entries const aufgabenTermine = aufgabenArr .filter(a => a.due_date) .map(a => ({ id: a.id, datum: a.due_date, titel: a.title || a.titel || 'Aufgabe', typ: 'deadline', beschreibung: a.description || '', projekt_id: a.project_id || '', _source: 'aufgabe' })); kalenderTermine = [...kalenderTermine, ...aufgabenTermine]; kalenderProjekte = projArr; // Populate project filter const projSelect = document.getElementById('kalenderFilterProjekt'); if (projSelect) { projSelect.innerHTML = '' + kalenderProjekte.map(p => ``).join(''); } updateKalenderKpis(); renderCurrentKalenderView(); renderUpcomingTermine(); } catch(e) { console.error('Kalender load error:', e); showToast('Kalender konnte nicht geladen werden', 'error'); } } function updateKalenderKpis() { const now = new Date(); const todayStr = dateToDatumStr(now); const monday = getMondayOfWeek(now); const sunday = new Date(monday); sunday.setDate(sunday.getDate() + 6); const mondayStr = dateToDatumStr(monday); const sundayStr = dateToDatumStr(sunday); document.getElementById('kalKpiGesamt').textContent = kalenderTermine.length; document.getElementById('kalKpiWoche').textContent = kalenderTermine.filter(t => t.datum >= mondayStr && t.datum <= sundayStr).length; document.getElementById('kalKpiHeute').textContent = kalenderTermine.filter(t => t.datum === todayStr).length; document.getElementById('kalKpiUeberfaellig').textContent = kalenderTermine.filter(t => t.typ === 'deadline' && t.datum < todayStr).length; } function setKalenderView(view) { kalenderView = view; ['viewMonat','viewWoche','viewTag'].forEach(id => { const el = document.getElementById(id); if (el) el.style.background = id === 'view' + view.charAt(0).toUpperCase() + view.slice(1) ? 'var(--color-cyan)' : ''; if (el) el.style.color = id === 'view' + view.charAt(0).toUpperCase() + view.slice(1) ? '#fff' : ''; }); renderCurrentKalenderView(); } function renderCurrentKalenderView() { if (kalenderView === 'monat') renderKalenderMonat(); else if (kalenderView === 'woche') renderKalenderWoche(); else renderKalenderTag(); renderUpcomingTermine(); } function renderKalenderMonat() { const year = currentCalendarMonth.getFullYear(); const month = currentCalendarMonth.getMonth(); const filtered = getFilteredTermine(); document.getElementById('kalenderMonthTitle').textContent = `${germanMonths[month]} ${year}`; let html = '
'; germanDays.forEach(d => { html += `
${d}
`; }); const firstDay = new Date(year, month, 1); let startDay = firstDay.getDay() - 1; if (startDay < 0) startDay = 6; const daysInMonth = new Date(year, month + 1, 0).getDate(); const today = new Date(); const todayStr = dateToDatumStr(today); for (let i = 0; i < startDay; i++) { html += '
'; } for (let day = 1; day <= daysInMonth; day++) { const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(day).padStart(2,'0')}`; const isToday = dateStr === todayStr; const dayTermine = getTermineForDate(dateStr, filtered); html += `
`; html += `
${day}
`; dayTermine.slice(0, 3).forEach(t => { const color = terminColors[t.typ] || '#22B8CF'; const repeatIcon = t.is_recurring ? '🔄 ' : ''; html += `
${repeatIcon}${escapeHtml(t.titel||'')}
`; }); if (dayTermine.length > 3) { html += `
+${dayTermine.length - 3} weitere
`; } html += '
'; } const totalCells = startDay + daysInMonth; const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); for (let i = 0; i < remaining; i++) { html += '
'; } html += '
'; document.getElementById('kalenderGrid').innerHTML = html; } function renderKalenderWoche() { const monday = getMondayOfWeek(currentCalendarMonth); const sunday = new Date(monday); sunday.setDate(sunday.getDate() + 6); const filtered = getFilteredTermine(); document.getElementById('kalenderMonthTitle').textContent = `${monday.getDate()}.${monday.getMonth()+1}. – ${sunday.getDate()}.${sunday.getMonth()+1}.${sunday.getFullYear()}`; const hours = []; for (let h = 6; h <= 20; h++) hours.push(h); const todayStr = dateToDatumStr(new Date()); let html = '
'; // Header html += '
Zeit
'; for (let i = 0; i < 7; i++) { const d = new Date(monday); d.setDate(d.getDate() + i); const ds = dateToDatumStr(d); const isToday = ds === todayStr; html += `
${germanDays[i]} ${d.getDate()}.${d.getMonth()+1}.
`; } hours.forEach(h => { html += `
${String(h).padStart(2,'0')}:00
`; for (let i = 0; i < 7; i++) { const d = new Date(monday); d.setDate(d.getDate() + i); const ds = dateToDatumStr(d); const isToday = ds === todayStr; const dayTermine = getTermineForDate(ds, filtered).filter(t => { if (!t.zeit_von) return h === 8; // default 8:00 const tHour = parseInt(t.zeit_von.split(':')[0] || '8'); return tHour === h; }); html += `
`; dayTermine.forEach(t => { const color = terminColors[t.typ] || '#22B8CF'; const timeStr = t.zeit_von ? `${t.zeit_von}${t.zeit_bis?' - '+t.zeit_bis:''}` : ''; const repeatIcon = t.is_recurring ? '🔄 ' : ''; html += `
${repeatIcon}${escapeHtml(t.titel||'')}${timeStr?' '+timeStr:''}
`; }); html += '
'; } }); html += '
'; document.getElementById('kalenderGrid').innerHTML = html; } function renderKalenderTag() { const d = currentCalendarMonth; const dateStr = dateToDatumStr(d); const todayStr = dateToDatumStr(new Date()); const isToday = dateStr === todayStr; const dayIdx = (d.getDay() + 6) % 7; const filtered = getFilteredTermine(); document.getElementById('kalenderMonthTitle').textContent = `${germanDaysFull[dayIdx]}, ${d.getDate()}. ${germanMonths[d.getMonth()]} ${d.getFullYear()}`; const dayTermine = getTermineForDate(dateStr, filtered); const hours = []; for (let h = 6; h <= 20; h++) hours.push(h); let html = '
'; if (isToday) html += '
Heute
'; hours.forEach(h => { const hTermine = dayTermine.filter(t => { if (!t.zeit_von) return h === 8; return parseInt(t.zeit_von.split(':')[0] || '8') === h; }); html += `
`; html += `
${String(h).padStart(2,'0')}:00
`; html += `
`; hTermine.forEach(t => { const color = terminColors[t.typ] || '#22B8CF'; const timeStr = t.zeit_von ? `${t.zeit_von}${t.zeit_bis?' – '+t.zeit_bis:''}` : ''; const repeatIcon = t.is_recurring ? '🔄 ' : ''; html += `
${repeatIcon}${escapeHtml(t.titel||'')}
${timeStr} ${t.beschreibung?'· '+escapeHtml(t.beschreibung.substring(0,60)):''}
`; }); html += '
'; }); // All-day / no-time events const allDay = dayTermine.filter(t => !t.zeit_von); if (allDay.length > 0 && allDay.some(t => parseInt((t.zeit_von||'08').split(':')[0]) !== 8)) { // already shown at 8:00 } html += '
'; document.getElementById('kalenderGrid').innerHTML = html; } function renderUpcomingTermine() { const todayStr = dateToDatumStr(new Date()); const filtered = getFilteredTermine(); const upcoming = filtered .filter(t => t.datum >= todayStr) .sort((a, b) => a.datum.localeCompare(b.datum)) .slice(0, 10); const html = upcoming.length > 0 ? upcoming.map(t => { const d = new Date(t.datum + 'T00:00:00'); const title = t.titel || 'Ohne Titel'; const typ = t.typ || 'termin'; const dateStr = d.toLocaleString('de-DE', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); const timeStr = t.zeit_von ? ` · ${t.zeit_von}${t.zeit_bis?' – '+t.zeit_bis:''}` : ''; const color = terminColors[typ] || '#22B8CF'; const repeatIcon = t.is_recurring ? '🔄 ' : ''; return `
${repeatIcon}${escapeHtml(title)}
${dateStr}${timeStr}
${typ}
`; }).join('') : '

Keine anstehenden Termine

'; document.getElementById('kalenderUpcoming').innerHTML = html; } function kalenderPrev() { if (kalenderView === 'monat') currentCalendarMonth.setMonth(currentCalendarMonth.getMonth() - 1); else if (kalenderView === 'woche') currentCalendarMonth.setDate(currentCalendarMonth.getDate() - 7); else currentCalendarMonth.setDate(currentCalendarMonth.getDate() - 1); renderCurrentKalenderView(); } function kalenderNext() { if (kalenderView === 'monat') currentCalendarMonth.setMonth(currentCalendarMonth.getMonth() + 1); else if (kalenderView === 'woche') currentCalendarMonth.setDate(currentCalendarMonth.getDate() + 7); else currentCalendarMonth.setDate(currentCalendarMonth.getDate() + 1); renderCurrentKalenderView(); } function kalenderToday() { currentCalendarMonth = new Date(); renderCurrentKalenderView(); } async function showNewTerminModal(prefillDate) { const today = prefillDate || new Date().toISOString().split('T')[0]; const projOptions = kalenderProjekte.map(p => ``).join(''); const html = `
`; openModal('Neuer Termin', html, async () => { const titel = document.getElementById('newTerminTitel').value.trim(); const datum = document.getElementById('newTerminDatum').value; if (!titel || !datum) { showToast('Titel und Datum sind erforderlich', 'error'); return; } try { const terminData = { titel, datum, zeit_von: document.getElementById('newTerminVon').value || '', zeit_bis: document.getElementById('newTerminBis').value || '', typ: document.getElementById('newTerminTyp').value, projekt_id: document.getElementById('newTerminProjekt').value || '', beschreibung: document.getElementById('newTerminBeschreibung').value.trim() }; const result = await apiCall('/termine', 'POST', terminData); const termin_id = result.id; // Create recurrence if specified const wiederkehr = document.getElementById('newTerminWiederkehr').value; if (wiederkehr) { const weekdayCheckboxes = document.querySelectorAll('.weekdayCheckbox:checked'); const byday = Array.from(weekdayCheckboxes).map(cb => cb.value).join(','); const endet = document.getElementById('newTerminEndet').value; const recurrenceData = { rrule_freq: wiederkehr, rrule_interval: parseInt(document.getElementById('newTerminIntervall').value) || 1, rrule_byday: byday || '', rrule_until: endet === 'datum' ? document.getElementById('newTerminBisAnDatum').value : '', rrule_count: endet === 'anzahl' ? parseInt(document.getElementById('newTerminAnzahl').value) : null, exceptions: '[]' }; await apiCall(`/termine/${termin_id}/recurrence`, 'POST', recurrenceData); } showToast('Termin erstellt', 'success'); closeModal(); loadKalender(); } catch(e) { showToast('Fehler: ' + (e.message || 'Termin konnte nicht erstellt werden'), 'error'); } }); document.getElementById('modalSubmitBtn').textContent = 'Erstellen'; } function showTerminDetails(id, source) { if (!id) return; const termin = kalenderTermine.find(t => t.id === id); if (!termin) { showToast('Termin nicht gefunden', 'error'); return; } const titel = termin.titel || ''; const d = new Date(termin.datum + 'T00:00:00'); const dateStr = d.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }); const timeStr = termin.zeit_von ? `${termin.zeit_von}${termin.zeit_bis?' – '+termin.zeit_bis:''}` : 'Ganztägig'; const typ = termin.typ || 'termin'; const color = terminColors[typ] || '#22B8CF'; const projekt = kalenderProjekte.find(p => p.id === termin.projekt_id); const isAufgabe = (source === 'aufgabe' || termin._source === 'aufgabe'); const html = `
${escapeHtml(titel)}
${typ}
Datum
${dateStr}
Uhrzeit
${timeStr}
${projekt ? `
Projekt
${escapeHtml(projekt.name||projekt.titel||'')}
` : ''} ${termin.beschreibung ? `
Beschreibung
${escapeHtml(termin.beschreibung)}
` : ''} ${!isAufgabe ? `
` : '
Dieser Eintrag stammt aus den Aufgaben und kann dort bearbeitet werden.
'}
`; openModal('Termindetails', html); const submitBtn = document.getElementById('modalSubmitBtn'); if (submitBtn) submitBtn.style.display = 'none'; } async function editTermin(id) { const termin = kalenderTermine.find(t => t.id === id); if (!termin) return; closeModal(); const projOptions = kalenderProjekte.map(p => ``).join(''); const html = `
`; openModal('Termin bearbeiten', html, async () => { const titel = document.getElementById('editTerminTitel').value.trim(); const datum = document.getElementById('editTerminDatum').value; if (!titel || !datum) { showToast('Titel und Datum erforderlich', 'error'); return; } try { await apiCall('/termine/' + id, 'PATCH', { titel, datum, zeit_von: document.getElementById('editTerminVon').value || '', zeit_bis: document.getElementById('editTerminBis').value || '', typ: document.getElementById('editTerminTyp').value, projekt_id: document.getElementById('editTerminProjekt').value || '', beschreibung: document.getElementById('editTerminBeschreibung').value.trim() }); showToast('Termin aktualisiert', 'success'); closeModal(); loadKalender(); } catch(e) { showToast('Fehler: ' + (e.message || 'Update fehlgeschlagen'), 'error'); } }); document.getElementById('modalSubmitBtn').textContent = 'Speichern'; } async function deleteTermin(id) { if (!confirm('Termin wirklich löschen?')) return; try { await apiCall('/termine/' + id, 'DELETE'); showToast('Termin gelöscht', 'success'); closeModal(); loadKalender(); } catch(e) { showToast('Fehler beim Löschen', 'error'); } } // ===== KEYBOARD SHORTCUTS ===== function showKeyboardShortcuts() { const content = `

Navigation

Alt+1 Dashboard
Alt+2 Projekte
Alt+3 Baustellen
Alt+4 Rechnungen
Alt+5 Angebote
Alt+6 Aufgaben
Alt+7 Personal
Alt+8 Material
Alt+9 Kunden

Aktionen

Ctrl+K Suche
? Diese Hilfe
`; openModal('Tastenkürzel', content); } // ===== DARK MODE ===== function initDarkMode() { const saved = localStorage.getItem('baugenio-theme') || 'dark'; document.documentElement.setAttribute('data-theme', saved); if (saved === 'dark') { document.body.classList.add('dark-mode'); } } function toggleTheme() { const current = document.body.classList.contains('dark-mode'); if (current) { document.body.classList.remove('dark-mode'); localStorage.setItem('darkMode', 'false'); localStorage.setItem('baugenio-theme', 'light'); } else { document.body.classList.add('dark-mode'); localStorage.setItem('darkMode', 'true'); localStorage.setItem('baugenio-theme', 'dark'); } updateDarkModeButton(); } // ===== NOTIFICATIONS ===== function initNotificationBell() { const header = document.querySelector('.topbar-controls'); if (!header) return; const bell = document.createElement('div'); bell.className = 'notification-bell'; bell.innerHTML = ` `; header.insertBefore(bell, header.firstChild); loadNotificationCount(); setInterval(loadNotificationCount, 60000); // Check every minute } async function loadNotificationCount() { try { const data = await apiCall('/api/v1/benachrichtigungen?limit=10&unread=true'); const badge = document.getElementById('notif-badge'); if (data && Array.isArray(data.benachrichtigungen || data)) { const items = data.benachrichtigungen || data; const count = items.length; if (badge) { badge.textContent = count > 99 ? '99+' : count; badge.style.display = count > 0 ? 'inline-block' : 'none'; } } } catch (e) { /* Silent fail for notification check */ } } function toggleNotifications() { const dropdown = document.getElementById('notif-dropdown'); if (!dropdown) return; if (dropdown.style.display === 'none') { dropdown.style.display = 'block'; loadNotifications(dropdown); } else { dropdown.style.display = 'none'; } } async function loadNotifications(dropdown) { dropdown.innerHTML = '
Laden...
'; try { const data = await apiCall('/api/v1/benachrichtigungen?limit=20'); const items = data.benachrichtigungen || data || []; if (items.length === 0) { dropdown.innerHTML = '
Keine Benachrichtigungen
'; return; } let html = ''; items.slice(0, 10).forEach(n => { html += `
${escapeHtml(n.titel || n.title || n.nachricht || '-')}
${formatDate(n.created_at || '')}
`; }); dropdown.innerHTML = html; } catch (e) { dropdown.innerHTML = '
Fehler beim Laden
'; } } // ===== INIT ===== // ===== GLOBAL ERROR HANDLING ===== window.onerror = function(msg, source, line, col, error) { console.error('Global error:', msg, 'at', source, line, col); // Don't show toast for ResizeObserver or benign errors if (msg && !String(msg).includes('ResizeObserver') && !String(msg).includes('Script error')) { showToast?.('Ein Fehler ist aufgetreten', 'error'); } return false; }; window.addEventListener('unhandledrejection', function(event) { console.error('Unhandled promise rejection:', event.reason); if (event.reason?.message && !event.reason.message.includes('401')) { showToast?.('Netzwerkfehler: ' + (event.reason.message || 'Unbekannt'), 'error'); } event.preventDefault(); }); // ===== FORM VALIDATION HELPERS ===== function validateRequired(fields) { for (const [id, label] of Object.entries(fields)) { const el = document.getElementById(id); if (!el) continue; const val = el.value?.trim(); if (!val) { el.style.borderColor = '#ef4444'; el.focus(); showToast(`${label} ist erforderlich`, 'error'); setTimeout(() => el.style.borderColor = '', 3000); return false; } } return true; } function validateEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function validateNumber(val, min, max) { const n = parseFloat(val); if (isNaN(n)) return false; if (min !== undefined && n < min) return false; if (max !== undefined && n > max) return false; return true; } document.addEventListener('DOMContentLoaded', async () => { const token = localStorage.getItem('token'); if (token) { const userLoaded = await loadUser(); if (userLoaded) { showApp(); } else { logout(); } } else { document.getElementById('loginContainer').classList.remove('hidden'); } if (localStorage.getItem('darkMode') === 'true') { document.body.classList.add('dark-mode'); } updateDarkModeButton(); document.getElementById('modalOverlay').addEventListener('click', (e) => { if (e.target.id === 'modalOverlay') closeModal(); }); document.addEventListener('click', (e) => { if (!e.target.closest('.topbar-search')) { document.getElementById('searchDropdown').classList.remove('show'); } }); initLogin(); initDarkMode(); initNotificationBell(); // ===== KEYBOARD SHORTCUTS ===== document.addEventListener('keydown', function(e) { // Don't trigger shortcuts when typing in inputs if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; // Ctrl+K: Open command palette if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openCommandPalette(); return; } // Navigation shortcuts (without modifier) if (!e.ctrlKey && !e.metaKey && !e.altKey) { switch(e.key) { case '?': e.preventDefault(); showKeyboardShortcuts(); break; } } // Alt+Number for quick navigation if (e.altKey && !e.ctrlKey) { switch(e.key) { case '1': e.preventDefault(); goToPage('dashboard'); break; case '2': e.preventDefault(); goToPage('projects'); break; case '3': e.preventDefault(); goToPage('baustellen'); break; case '4': e.preventDefault(); goToPage('finanzen'); break; case '5': e.preventDefault(); goToPage('angebote'); break; case '6': e.preventDefault(); goToPage('aufgaben'); break; case '7': e.preventDefault(); goToPage('personal'); break; case '8': e.preventDefault(); goToPage('material'); break; case '9': e.preventDefault(); goToPage('kunden'); break; } } // Original shortcuts (maintained for backward compatibility) if (e.altKey) { switch(e.key) { case 'd': e.preventDefault(); goToPage('dashboard'); break; case 'a': e.preventDefault(); goToPage('angebote'); break; case 'p': e.preventDefault(); goToPage('projects'); break; case 'k': e.preventDefault(); goToPage('kalender'); break; case 'c': e.preventDefault(); goToPage('cashflow'); break; case 'b': e.preventDefault(); goToPage('bautagebuch'); break; case 'f': e.preventDefault(); goToPage('fuhrpark'); break; case 's': e.preventDefault(); const searchInput = document.querySelector('#globalSearch'); if (searchInput) searchInput.focus(); break; } } if (e.key === 'Escape') { closeModal(); } }); }); // ===== COMMAND PALETTE FUNCTIONS ===== function openCommandPalette() { document.getElementById('commandPaletteOverlay').style.display = 'block'; const input = document.getElementById('commandPaletteInput'); input.value = ''; input.focus(); showCommandDefaults(); } function closeCommandPalette() { document.getElementById('commandPaletteOverlay').style.display = 'none'; } function showCommandDefaults() { const pages = [ {icon:'📊',label:'Dashboard',action:()=>goToPage('dashboard')}, {icon:'🏗️',label:'Projekte',action:()=>goToPage('projects')}, {icon:'📋',label:'Aufgaben',action:()=>goToPage('aufgaben')}, {icon:'👥',label:'Kunden',action:()=>goToPage('kunden')}, {icon:'💰',label:'Rechnungen',action:()=>goToPage('rechnungen')}, {icon:'📄',label:'Angebote',action:()=>goToPage('angebote')}, {icon:'📅',label:'Kalender',action:()=>goToPage('kalender')}, {icon:'⚠️',label:'Mängel',action:()=>goToPage('maengel')}, {icon:'📓',label:'Bautagebuch',action:()=>goToPage('bautagebuch')}, {icon:'🔧',label:'Plantafel',action:()=>goToPage('plantafel')}, {icon:'🚛',label:'Fuhrpark',action:()=>goToPage('fuhrpark')}, {icon:'📦',label:'Bestellwesen',action:()=>goToPage('bestellwesen')}, {icon:'💬',label:'Chat',action:()=>goToPage('chat')}, {icon:'📊',label:'Berichte',action:()=>goToPage('berichte')}, {icon:'⚙️',label:'Einstellungen',action:()=>goToPage('einstellungen')}, {icon:'🔍',label:'Audit-Log',action:()=>goToPage('audit-log')}, ]; renderCommandResults(pages, 'Seiten'); } let commandSelectedIndex = 0; let commandResults = []; function handleCommandSearch(query) { if (!query.trim()) { showCommandDefaults(); return; } const q = query.toLowerCase(); // Static pages const pages = [ {icon:'📊',label:'Dashboard',keywords:'start übersicht home',action:()=>goToPage('dashboard')}, {icon:'🏗️',label:'Projekte',keywords:'bauprojekte vorhaben',action:()=>goToPage('projects')}, {icon:'📋',label:'Aufgaben',keywords:'tasks todos',action:()=>goToPage('aufgaben')}, {icon:'👥',label:'Kunden',keywords:'kontakte firmen',action:()=>goToPage('kunden')}, {icon:'💰',label:'Rechnungen',keywords:'invoices zahlung',action:()=>goToPage('rechnungen')}, {icon:'📄',label:'Angebote',keywords:'quotes offerte',action:()=>goToPage('angebote')}, {icon:'📅',label:'Kalender',keywords:'termine calendar',action:()=>goToPage('kalender')}, {icon:'⚠️',label:'Mängel',keywords:'defekte probleme',action:()=>goToPage('maengel')}, {icon:'📓',label:'Bautagebuch',keywords:'tagebuch protokoll',action:()=>goToPage('bautagebuch')}, {icon:'🔧',label:'Plantafel',keywords:'personal ressourcen',action:()=>goToPage('plantafel')}, {icon:'🚛',label:'Fuhrpark',keywords:'fahrzeuge geräte',action:()=>goToPage('fuhrpark')}, {icon:'📦',label:'Bestellwesen',keywords:'einkauf material',action:()=>goToPage('bestellwesen')}, {icon:'💬',label:'Chat',keywords:'nachrichten team',action:()=>goToPage('chat')}, {icon:'⚙️',label:'Einstellungen',keywords:'settings profil firma',action:()=>goToPage('einstellungen')}, ]; const filtered = pages.filter(p => p.label.toLowerCase().includes(q) || (p.keywords && p.keywords.toLowerCase().includes(q)) ); // Also search API if query length > 2 if (q.length > 2) { apiCall('/search?q=' + encodeURIComponent(q)).then(res => { const apiResults = toArr(res).map(r => ({ icon: r.type === 'project' ? '🏗️' : r.type === 'kunde' ? '👥' : r.type === 'rechnung' ? '💰' : '📋', label: r.title || r.name || r.id, detail: r.type, action: () => { if(r.type==='project') goToPage('projects'); else if(r.type==='kunde') goToPage('kunden'); } })); renderCommandResults([...filtered, ...apiResults], 'Ergebnisse'); }).catch(() => { renderCommandResults(filtered, 'Seiten'); }); } else { renderCommandResults(filtered, 'Seiten'); } } function renderCommandResults(items, title) { commandResults = items; commandSelectedIndex = 0; const container = document.getElementById('commandPaletteResults'); if (!items.length) { container.innerHTML = '
Keine Ergebnisse
'; return; } let html = `
${title}
`; items.forEach((item, i) => { html += `
${item.icon}
${escapeHtml(item.label)}
${item.detail?'
'+escapeHtml(item.detail)+'
':''}
`; }); container.innerHTML = html; } function highlightCommand() { document.querySelectorAll('.cmd-result').forEach((el, i) => { el.style.background = i === commandSelectedIndex ? 'var(--bg-tertiary)' : ''; }); } function executeCommand(index) { if (commandResults[index] && commandResults[index].action) { closeCommandPalette(); commandResults[index].action(); } } function handleCommandKeydown(e) { if (e.key === 'Escape') { closeCommandPalette(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); commandSelectedIndex = Math.min(commandSelectedIndex + 1, commandResults.length - 1); highlightCommand(); return; } if (e.key === 'ArrowUp') { e.preventDefault(); commandSelectedIndex = Math.max(commandSelectedIndex - 1, 0); highlightCommand(); return; } if (e.key === 'Enter') { e.preventDefault(); executeCommand(commandSelectedIndex); return; } } // ===== KUNDEN MODULE ===== let kundenData = []; let kundenFiltered = []; let kundenCurrentFilter = 'alle'; async function loadKunden() { try { const data = await apiCall('/kunden'); kundenData = data || []; kundenFiltered = kundenData; updateKundenKpis(); renderKundenFilterButtons(); renderKundenTable(); } catch (e) { showToast('Fehler beim Laden der Kunden', 'error'); } } function updateKundenKpis() { const total = kundenData.length; const active = kundenData.filter(k => k.status === 'aktiv').length; const interested = kundenData.filter(k => k.status === 'interessent').length; const totalRevenue = kundenData.reduce((sum, k) => sum + (parseFloat(k.umsatz) || 0), 0); const kpiHtml = `
Gesamt
${total}
Aktive
${active}
Interessenten
${interested}
Gesamtumsatz
${formatCurrency(totalRevenue)}
`; document.getElementById('kundenKpis').innerHTML = kpiHtml; } function renderKundenFilterButtons() { const types = ['alle', 'auftraggeber', 'wbg', 'hausverwaltung', 'investor', 'fm']; const buttons = types.map(type => { const label = type.charAt(0).toUpperCase() + type.slice(1); const active = kundenCurrentFilter === type ? 'style="background: var(--primary-color); color: white;"' : ''; return ``; }).join(''); document.getElementById('kundenFilterButtons').innerHTML = buttons; } function filterKunden(filterType) { kundenCurrentFilter = filterType; if (filterType === 'alle') { kundenFiltered = kundenData; } else { kundenFiltered = kundenData.filter(k => k.typ && k.typ.toLowerCase() === filterType.toLowerCase()); } renderKundenFilterButtons(); renderKundenTable(); } function searchKunden() { const query = document.getElementById('kundenSearch').value.toLowerCase(); kundenFiltered = kundenData.filter(k => { const firma = (k.firma || '').toLowerCase(); const ansprechpartner = (k.ansprechpartner || '').toLowerCase(); return firma.includes(query) || ansprechpartner.includes(query); }); renderKundenTable(); } function renderKundenTable() { const tbody = document.getElementById('kundenTableBody'); if (!kundenFiltered || kundenFiltered.length === 0) { tbody.innerHTML = 'Keine Kunden gefunden'; return; } const rows = kundenFiltered.map(k => ` ${k.firma || '-'} ${k.ansprechpartner || '-'} ${k.email || '-'} ${k.telefon || '-'} ${k.typ || '-'} ${k.projekte_count || 0} ${formatCurrency(parseFloat(k.umsatz) || 0)} ${k.status || '-'} `).join(''); tbody.innerHTML = rows; } function showNewKundeModal() { const content = `
`; openModal('Neuer Kunde', content); document.getElementById('modalSubmitBtn').textContent = 'Speichern'; document.getElementById('modalSubmitBtn').onclick = saveKunde; } async function saveKunde() { const kundeData = { firma: document.getElementById('kundeFormFirma').value, ansprechpartner: document.getElementById('kundeFormAnsprechpartner').value, email: document.getElementById('kundeFormEmail').value, telefon: document.getElementById('kundeFormTelefon').value, typ: document.getElementById('kundeFormTyp').value }; if (!kundeData.firma) { showToast('Bitte Firma eingeben', 'error'); return; } try { await apiCall('/kunden', 'POST', kundeData); showToast('Kunde gespeichert', 'success'); closeModal(); loadKunden(); } catch (e) { showToast('Fehler beim Speichern', 'error'); } } async function showKundeDetail(kundeId) { try { // Fetch customer data and related data in parallel const [kundeRes, projektRes, rechnungRes, angeboteRes] = await Promise.all([ apiCall(`/kunden/${kundeId}`), apiCall('/projects'), apiCall('/rechnungen'), apiCall('/angebote') ]); const kunde = kundeRes; const kundeProjekte = toArr(projektRes).filter(p => p.kunde_id === kundeId); const kundeRechnungen = toArr(rechnungRes).filter(r => r.kunde_id === kundeId); const kundeAngebote = toArr(angeboteRes).filter(a => a.kunde_id === kundeId); // Calculate financial summary const openRechnungen = kundeRechnungen.filter(r => r.status === 'offen' || r.status === 'teilweise_bezahlt'); const totalOpenAmount = openRechnungen.reduce((sum, r) => sum + (parseFloat(r.betrag) || 0), 0); const recentRechnungen = kundeRechnungen.slice(-5).reverse(); // Build comprehensive HTML const content = `

${escapeHtml(kunde.firma || '-')}

${escapeHtml(kunde.status || '-')} ${escapeHtml(kunde.typ || '-')}

Kontaktinformationen

Ansprechpartner: ${escapeHtml(kunde.ansprechpartner || '-')}
Email: ${kunde.email ? `${escapeHtml(kunde.email)}` : '-'}
Telefon: ${kunde.telefon ? `${escapeHtml(kunde.telefon)}` : '-'}
Adresse: ${escapeHtml(kunde.adresse || '-')}
${kunde.notizen ? `
Notizen: ${escapeHtml(kunde.notizen)}
` : ''}

Finanzübersicht

Gesamtumsatz
${formatCurrency(parseFloat(kunde.umsatz) || 0)}
Offene Rechnungen
${openRechnungen.length} (${formatCurrency(totalOpenAmount)})
Angebote
${kundeAngebote.length}
${recentRechnungen.length > 0 ? `

Letzte Rechnungen

${recentRechnungen.map(r => ` `).join('')}
Nummer Datum Betrag Status
${escapeHtml(r.nummer || '-')} ${formatDate(r.datum)} ${formatCurrency(parseFloat(r.betrag) || 0)} ${escapeHtml(r.status || '-')}
` : ''} ${kundeProjekte.length > 0 ? `

Projekte

${kundeProjekte.map(p => ` `).join('')}
Name Status Budget
${escapeHtml(p.name || '-')} ${escapeHtml(p.status || '-')} ${formatCurrency(parseFloat(p.budget) || 0)}
` : ''}
`; openModal(`Kunde: ${escapeHtml(kunde.firma || '')}`, content); // Hide submit button for display-only modal const btn = document.getElementById('modalSubmitBtn'); if(btn) btn.style.display = 'none'; } catch (e) { showToast('Fehler beim Laden der Details', 'error'); } } async function editKunde(kundeId) { try { const kunde = await apiCall(`/kunden/${kundeId}`); const formHtml = `
`; openModal(`Kunde bearbeiten: ${escapeHtml(kunde.firma || '')}`, formHtml, async () => { const updateData = { firma: document.getElementById('editKundeFormFirma').value, ansprechpartner: document.getElementById('editKundeFormAnsprechpartner').value, email: document.getElementById('editKundeFormEmail').value, telefon: document.getElementById('editKundeFormTelefon').value, adresse: document.getElementById('editKundeFormAdresse').value, typ: document.getElementById('editKundeFormTyp').value, status: document.getElementById('editKundeFormStatus').value, notizen: document.getElementById('editKundeFormNotizen').value }; if (!updateData.firma) { showToast('Bitte Firma eingeben', 'error'); return; } try { await apiCall(`/kunden/${kundeId}`, 'PATCH', updateData); showToast('Kunde aktualisiert', 'success'); closeModal(); loadKunden(); } catch (e) { showToast('Fehler beim Aktualisieren', 'error'); } }); document.getElementById('modalSubmitBtn').textContent = 'Speichern'; document.getElementById('modalSubmitBtn').style.display = ''; } catch (e) { showToast('Fehler beim Laden der Kundendaten', 'error'); } } async function deleteKunde(kundeId) { if (!confirm('Wirklich löschen?')) return; try { await apiCall(`/kunden/${kundeId}`, 'DELETE'); showToast('Kunde gelöscht', 'success'); loadKunden(); } catch (e) { showToast('Fehler beim Löschen', 'error'); } } // ===== ZEITERFASSUNG MODULE ===== let zeitData = []; async function loadZeiterfassung() { try { const data = await apiCall('/zeiterfassung'); zeitData = data || []; updateZeitKpis(); renderZeitTable(); } catch (e) { showToast('Fehler beim Laden der Zeiterfassung', 'error'); } } function updateZeitKpis() { const today = new Date().toISOString().split('T')[0]; const thisWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const todayHours = zeitData .filter(z => z.datum === today) .reduce((sum, z) => sum + (parseFloat(z.stunden) || 0), 0); const weekHours = zeitData .filter(z => z.datum >= thisWeek && z.datum <= today) .reduce((sum, z) => sum + (parseFloat(z.stunden) || 0), 0); const laufend = zeitData.filter(z => z.status === 'laufend').length; const kpiHtml = `
Heute
${todayHours.toFixed(1)}h
Diese Woche
${weekHours.toFixed(1)}h
Laufend
${laufend}
`; document.getElementById('zeitKpis').innerHTML = kpiHtml; } function renderZeitTable() { const tbody = document.getElementById('zeitTableBody'); if (!zeitData || zeitData.length === 0) { tbody.innerHTML = 'Keine Einträge'; return; } const rows = zeitData.map(z => ` ${z.personal || '-'} ${formatDate(z.datum)} ${z.start || '-'} ${z.ende || '-'} ${z.pause || 0} min ${(parseFloat(z.stunden) || 0).toFixed(2)}h ${z.tätigkeit || '-'} ${z.status || '-'} `).join(''); tbody.innerHTML = rows; } function showNewZeitModal() { const today = new Date().toISOString().split('T')[0]; const content = `
`; openModal('Neue Zeiterfassung', content); document.getElementById('modalSubmitBtn').textContent = 'Speichern'; document.getElementById('modalSubmitBtn').onclick = saveZeit; } async function saveZeit() { const zeitObj = { personal: document.getElementById('zeitFormPersonal').value, datum: document.getElementById('zeitFormDatum').value, start: document.getElementById('zeitFormStart').value, ende: document.getElementById('zeitFormEnde').value, pause: parseInt(document.getElementById('zeitFormPause').value) || 0, tätigkeit: document.getElementById('zeitFormTätigkeit').value, status: 'abgeschlossen' }; if (!zeitObj.personal || !zeitObj.datum) { showToast('Bitte erforderliche Felder ausfüllen', 'error'); return; } try { await apiCall('/zeiterfassung', 'POST', zeitObj); showToast('Zeiterfassung gespeichert', 'success'); closeModal(); loadZeiterfassung(); } catch (e) { showToast('Fehler beim Speichern', 'error'); } } async function deleteZeit(zeitId) { if (!confirm('Wirklich löschen?')) return; try { await apiCall(`/zeiterfassung/${zeitId}`, 'DELETE'); showToast('Eintrag gelöscht', 'success'); loadZeiterfassung(); } catch (e) { showToast('Fehler beim Löschen', 'error'); } } // ===== ANGEBOTE ===== let angeboteCache = []; // Cache for filtering async function loadAngebote() { try { const resp = await apiCall('/angebote'); const angebote = toArr(resp); angeboteCache = angebote; // Store for filtering // Update KPIs document.getElementById('angeboteCountKpi').textContent = angebote.length; const offen = angebote.filter(a => ['entwurf', 'versendet'].includes(a.status || 'entwurf')).length; document.getElementById('angeboteOpenKpi').textContent = offen; const accepted = angebote.filter(a => a.status === 'angenommen').length; document.getElementById('angeboteAcceptedKpi').textContent = accepted; // Calculate success rate const successRate = angebote.length > 0 ? Math.round((accepted / angebote.length) * 100) : 0; const totalBrutto = angebote.reduce((sum, a) => sum + (parseFloat(a.brutto) || 0), 0); // Update KPI labels to be more descriptive document.getElementById('angeboteOpenKpi').parentElement.previousElementSibling.textContent = 'Offen'; document.getElementById('angeboteAcceptedKpi').parentElement.previousElementSibling.textContent = 'Erfolgsquote'; document.getElementById('angeboteAcceptedKpi').textContent = successRate + '%'; document.getElementById('angeboteVolumeKpi').textContent = formatCurrency(totalBrutto); // Populate dropdowns populateProjektDropdown('angeboteProjektFilter'); populateKundeDropdown('angeboteKundeFilter'); // Attach filter listeners const statusFilter = document.getElementById('angeboteStatusFilter'); const projektFilter = document.getElementById('angeboteProjektFilter'); const kundeFilter = document.getElementById('angeboteKundeFilter'); if (statusFilter) statusFilter.addEventListener('change', () => renderAngeboteTable(angeboteCache)); if (projektFilter) projektFilter.addEventListener('change', () => renderAngeboteTable(angeboteCache)); if (kundeFilter) kundeFilter.addEventListener('change', () => renderAngeboteTable(angeboteCache)); // Render table renderAngeboteTable(angebote); } catch (err) { console.error('Error loading Angebote:', err); showToast('Fehler beim Laden der Angebote', 'error'); } } function renderAngeboteTable(angebote) { const tbody = document.getElementById('angeboteList'); // Apply filters const statusFilter = document.getElementById('angeboteStatusFilter')?.value || ''; const projektFilter = document.getElementById('angeboteProjektFilter')?.value || ''; const kundeFilter = document.getElementById('angeboteKundeFilter')?.value || ''; let filtered = angebote; if (statusFilter) { filtered = filtered.filter(a => a.status === statusFilter); } if (projektFilter) { filtered = filtered.filter(a => a.projekt_id === projektFilter); } if (kundeFilter) { filtered = filtered.filter(a => a.kunde_id === kundeFilter); } tbody.innerHTML = filtered.map(a => { const statusLabel = (a.status || 'entwurf') .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); return ` ${a.nummer || 'ANG-' + new Date().getFullYear() + '-001'} ${a.kunde_name || a.kunde || '-'} ${a.projekt_name || a.titel || '-'} ${formatCurrency(parseFloat(a.netto) || 0)} ${formatCurrency(parseFloat(a.brutto) || 0)} ${statusLabel} ${formatDate(a.gueltig_bis)} `; }).join(''); } function showNewAngebotModal() { openModal('Neues Angebot', `

Positionen

0,00 €
0,00 €
0,00 €
`, async () => { const kundeId = document.getElementById('newAngebotKunde').value; const projektId = document.getElementById('newAngebotProjekt').value; const frist = document.getElementById('newAngebotFrist').value; if (!kundeId || !projektId || !frist) { showToast('Bitte alle Felder ausfüllen', 'warning'); return; } try { await apiCall('/angebote', 'POST', { kunde_id: kundeId, projekt_id: projektId, gueltig_bis: frist }); showToast('Angebot erstellt', 'success'); loadAngebote(); } catch (err) { showToast('Fehler beim Erstellen des Angebots', 'error'); } }); } async function editAngebot(id) { try { const resp = await apiCall('/angebote/' + id); const angebot = toObj(resp); openModal('Angebot bearbeiten', `
`, async () => { try { await apiCall('/angebote/' + id, 'PATCH', { kunde_name: document.getElementById('editAngebotKunde').value, projekt_name: document.getElementById('editAngebotProjekt').value, status: document.getElementById('editAngebotStatus').value, gueltig_bis: document.getElementById('editAngebotGueltig').value }); showToast('Angebot aktualisiert', 'success'); loadAngebote(); } catch (err) { showToast('Fehler beim Speichern', 'error'); } }); } catch (err) { showToast('Fehler beim Laden des Angebots', 'error'); } } async function deleteAngebot(id) { if (confirm('Angebot wirklich löschen?')) { try { await apiCall('/angebote/' + id, 'DELETE'); showToast('Angebot gelöscht', 'success'); loadAngebote(); } catch (err) { showToast('Fehler beim Löschen', 'error'); } } } async function generateAngebotPDF(id) { try { // Fetch the Angebot details const resp = await apiCall('/angebote/' + id); const angebot = toObj(resp); // Create a simple PDF content structure const pdfWindow = window.open('', '', 'width=800,height=600'); const content = ` Angebot ${angebot.nummer}

ANGEBOT

Nummer: ${angebot.nummer || '-'}

Kunde

${angebot.kunde_name || angebot.kunde || '-'}

Projekt

${angebot.projekt_name || angebot.titel || '-'}

Gültig bis

${angebot.gueltig_bis ? new Date(angebot.gueltig_bis).toLocaleDateString('de-DE') : '-'}

Status

${(angebot.status || 'Entwurf').charAt(0).toUpperCase() + (angebot.status || 'Entwurf').slice(1)}
${angebot.beschreibung ? `
Beschreibung:
${angebot.beschreibung}
` : ''}
Position Beschreibung Betrag
1 ${angebot.titel || 'Leistungen'} ${formatCurrency(parseFloat(angebot.netto) || 0)}
Netto Betrag: ${formatCurrency(parseFloat(angebot.netto) || 0)}
MwSt. (${angebot.mwst_satz || 19}%): ${formatCurrency((parseFloat(angebot.brutto) || 0) - (parseFloat(angebot.netto) || 0))}
Gesamtbetrag (Brutto): ${formatCurrency(parseFloat(angebot.brutto) || 0)}
`; pdfWindow.document.write(content); pdfWindow.document.close(); pdfWindow.print(); } catch (err) { console.error('Error generating PDF:', err); showToast('Fehler beim Generieren der PDF', 'error'); } } function addAngebotPosition() { const posContainer = document.getElementById('angebotPositionen'); const posNum = posContainer.children.length; const html = `
`; posContainer.insertAdjacentHTML('beforeend', html); } // ===== NACHTRÄGE ===== async function loadNachtraege() { try { const resp = await apiCall('/nachtraege'); const nachtraege = toArr(resp); // Calculate KPIs const gesamt = nachtraege.length; const offen = nachtraege.filter(n => ['entwurf', 'eingereicht'].includes(n.status || 'entwurf')).length; const genehmigt = nachtraege.filter(n => n.status === 'genehmigt').length; const gesamtvolumen = nachtraege.reduce((sum, n) => sum + (parseFloat(n.kosten_netto) || 0), 0); // Render KPI Cards const kpiHtml = `
Gesamt Nachträge
${gesamt}
Offene (Entwurf/Eingereicht)
${offen}
Genehmigt
${genehmigt}
Gesamtvolumen
${formatCurrency(gesamtvolumen)}
`; // Render Table const tableHtml = ` ${nachtraege.map(n => { const beschreibung = (n.beschreibung || '').substring(0, 50) + (n.beschreibung && n.beschreibung.length > 50 ? '...' : ''); const typClass = n.typ === 'preisanpassung' ? 'badge-warning' : 'badge-info'; const typLabel = n.typ === 'preisanpassung' ? 'Preisanpassung' : 'Zusatzleistung'; const statusClass = { 'entwurf': 'badge-secondary', 'eingereicht': 'badge-info', 'genehmigt': 'badge-success', 'abgelehnt': 'badge-error' }[n.status] || 'badge-secondary'; const statusLabel = (n.status || 'entwurf').charAt(0).toUpperCase() + (n.status || 'entwurf').slice(1); return ` `; }).join('')}
Nr. Titel Beschreibung Typ Kosten Netto Status Frist Aktionen
${n.nummer || '-'} ${n.titel || '-'} ${beschreibung} ${typLabel} ${formatCurrency(parseFloat(n.kosten_netto) || 0)} ${statusLabel} ${formatDate(n.frist)}
`; // Clear old display and render new content document.getElementById('nachtraegeCountKpi').textContent = gesamt; document.getElementById('nachtraegeOpenKpi').textContent = offen; document.getElementById('nachtraegeApprovedKpi').textContent = genehmigt; document.getElementById('nachtraegeVolumeKpi').textContent = formatCurrency(gesamtvolumen); const container = document.getElementById('nachtraegeList').parentElement; container.innerHTML = kpiHtml + '
' + tableHtml + '
'; populateProjektDropdown('nachtraegeProjektFilter'); } catch (err) { console.error('Error loading Nachträge:', err); showToast('Fehler beim Laden der Nachträge', 'error'); } } function renderNachtraegeTable(nachtraege) { const tbody = document.getElementById('nachtraegeList'); tbody.innerHTML = nachtraege.map(n => ` ${n.nummer || 'NCH-' + new Date().getFullYear() + '-001'} ${n.projekt_name || '-'} ${n.titel || '-'} ${n.typ || 'Zusatzleistung'} ${formatCurrency(parseFloat(n.kosten) || 0)} ${(n.status || 'entwurf').charAt(0).toUpperCase() + (n.status || 'entwurf').slice(1)} ${formatDate(n.frist)} `).join(''); } function showNewNachtragModal() { openModal('Neuer Nachtrag', `
`, async () => { const projektId = document.getElementById('newNachtragProjekt').value; const titel = document.getElementById('newNachtragTitel').value; const typ = document.getElementById('newNachtragTyp').value; const kosten = document.getElementById('newNachtragKosten').value; const frist = document.getElementById('newNachtragFrist').value; if (!projektId || !titel || !kosten) { showToast('Bitte alle Pflichtfelder ausfüllen', 'warning'); return; } try { await apiCall('/nachtraege', 'POST', { projekt_id: projektId, titel, typ, kosten: parseFloat(kosten), frist }); showToast('Nachtrag erstellt', 'success'); loadNachtraege(); } catch (err) { showToast('Fehler beim Erstellen des Nachtrags', 'error'); } }); } async function editNachtrag(id) { try { const resp = await apiCall('/nachtraege/' + id); const nachtrag = toObj(resp); openModal('Nachtrag bearbeiten', `
`, async () => { try { await apiCall('/nachtraege/' + id, 'PATCH', { titel: document.getElementById('editNachtragTitel').value, typ: document.getElementById('editNachtragTyp').value, kosten: parseFloat(document.getElementById('editNachtragKosten').value), status: document.getElementById('editNachtragStatus').value, frist: document.getElementById('editNachtragFrist').value }); showToast('Nachtrag aktualisiert', 'success'); loadNachtraege(); } catch (err) { showToast('Fehler beim Speichern', 'error'); } }); } catch (err) { showToast('Fehler beim Laden des Nachtrags', 'error'); } } async function deleteNachtrag(id) { if (confirm('Nachtrag wirklich löschen?')) { try { await apiCall('/nachtraege/' + id, 'DELETE'); showToast('Nachtrag gelöscht', 'success'); loadNachtraege(); } catch (err) { showToast('Fehler beim Löschen', 'error'); } } } // ===== MÄNGEL-MANAGEMENT PAGE ===== let allMaengelPage = []; let maengelProjects = []; let maengelBaustellen = []; async function loadMaengelPage() { try { const [maengel, projects, baustellen] = await Promise.all([ apiCall('/maengel'), apiCall('/projects'), apiCall('/baustellen').catch(() => []) ]); allMaengelPage = Array.isArray(maengel) ? maengel : (maengel.items || maengel.maengel || []); maengelProjects = Array.isArray(projects) ? projects : (projects.projects || []); maengelBaustellen = Array.isArray(baustellen) ? baustellen : (baustellen.baustellen || []); // Populate filter dropdowns const projSelect = document.getElementById('maengelFilterProjekt'); if (projSelect) { projSelect.innerHTML = '' + maengelProjects.map(p => ``).join(''); } const gewerkSet = new Set(allMaengelPage.map(m => m.gewerk).filter(Boolean)); const gewerkSelect = document.getElementById('maengelFilterGewerk'); if (gewerkSelect) { gewerkSelect.innerHTML = '' + [...gewerkSet].sort().map(g => ``).join(''); } // Populate baustellen in create modal const bSelect = document.getElementById('newMangelBaustelle'); if (bSelect) { bSelect.innerHTML = '' + maengelBaustellen.map(b => ``).join(''); } updateMaengelKpis(); filterMaengelPage(); } catch (e) { document.getElementById('maengelList').innerHTML = `

Fehler: ${e.message}

`; } } function updateMaengelKpis() { const today = new Date().toISOString().split('T')[0]; const gesamt = allMaengelPage.length; const offen = allMaengelPage.filter(m => m.status === 'offen').length; const inArbeit = allMaengelPage.filter(m => m.status === 'in_arbeit').length; const behoben = allMaengelPage.filter(m => m.status === 'behoben' || m.status === 'abgenommen').length; const ueberfaellig = allMaengelPage.filter(m => m.frist && m.frist < today && !['behoben','abgenommen'].includes(m.status)).length; const el = id => document.getElementById(id); if (el('maengelKpiGesamt')) el('maengelKpiGesamt').textContent = gesamt; if (el('maengelKpiOffen')) el('maengelKpiOffen').textContent = offen; if (el('maengelKpiInArbeit')) el('maengelKpiInArbeit').textContent = inArbeit; if (el('maengelKpiBehoben')) el('maengelKpiBehoben').textContent = behoben; if (el('maengelKpiUeberfaellig')) el('maengelKpiUeberfaellig').textContent = ueberfaellig; } function filterMaengelPage() { const search = (document.getElementById('maengelSearch')?.value || '').toLowerCase(); const status = document.getElementById('maengelFilterStatus')?.value || ''; const projekt = document.getElementById('maengelFilterProjekt')?.value || ''; const gewerk = document.getElementById('maengelFilterGewerk')?.value || ''; const schwere = document.getElementById('maengelFilterSchwere')?.value || ''; const today = new Date().toISOString().split('T')[0]; let filtered = allMaengelPage.filter(m => { if (search && !(m.titel || '').toLowerCase().includes(search) && !(m.beschreibung || '').toLowerCase().includes(search) && !(m.ort || '').toLowerCase().includes(search)) return false; if (status && m.status !== status) return false; if (projekt && m.projekt_id !== projekt) return false; if (gewerk && m.gewerk !== gewerk) return false; if (schwere && m.schwere !== schwere) return false; return true; }); // Sort: open/overdue first, then by created_at desc filtered.sort((a, b) => { const aUrgent = (a.frist && a.frist < today && !['behoben','abgenommen'].includes(a.status)) ? 0 : 1; const bUrgent = (b.frist && b.frist < today && !['behoben','abgenommen'].includes(b.status)) ? 0 : 1; if (aUrgent !== bUrgent) return aUrgent - bUrgent; return (b.created_at || '').localeCompare(a.created_at || ''); }); renderMaengelList(filtered, today); } function renderMaengelList(items, today) { const container = document.getElementById('maengelList'); if (!container) return; if (!items.length) { container.innerHTML = '

Keine Mängel gefunden.

'; return; } const statusColors = { offen: '#ef4444', in_arbeit: '#f59e0b', behoben: '#22c55e', abgenommen: '#3b82f6' }; const statusLabels = { offen: 'Offen', in_arbeit: 'In Arbeit', behoben: 'Behoben', abgenommen: 'Abgenommen' }; const schwereColors = { gering: '#6b7280', mittel: '#f59e0b', hoch: '#ef4444', kritisch: '#dc2626' }; container.innerHTML = items.map(m => { const isOverdue = m.frist && m.frist < today && !['behoben','abgenommen'].includes(m.status); const color = statusColors[m.status] || '#6b7280'; const projekt = maengelProjects.find(p => p.id === m.projekt_id); const fotoCount = (() => { try { return JSON.parse(m.fotos || '[]').length; } catch { return 0; } })(); return `
${escapeHtml(m.mangel_nr || '')} ${statusLabels[m.status] || m.status} ${m.schwere && m.schwere !== 'mittel' ? `${m.schwere}` : ''} ${isOverdue ? 'ÜBERFÄLLIG' : ''}
${escapeHtml(m.titel || '')}
${projekt ? escapeHtml(projekt.name) : ''} ${m.gewerk ? ' · ' + escapeHtml(m.gewerk) : ''} ${m.ort ? ' · ' + escapeHtml(m.ort) : ''}
${(m.created_at || '').substring(0, 10)}
${m.frist ? `
Frist: ${m.frist}
` : ''} ${fotoCount > 0 ? `
📷 ${fotoCount}
` : ''} ${m.verantwortlich ? `
${escapeHtml(m.verantwortlich)}
` : ''}
`; }).join(''); } async function showMangelDetail(id) { try { const m = await apiCall('/maengel/' + id); const modal = document.getElementById('mangelDetailModal'); const title = document.getElementById('mangelDetailTitle'); const body = document.getElementById('mangelDetailBody'); const statusColors = { offen: '#ef4444', in_arbeit: '#f59e0b', behoben: '#22c55e', abgenommen: '#3b82f6' }; const color = statusColors[m.status] || '#6b7280'; const projekt = maengelProjects.find(p => p.id === m.projekt_id); title.textContent = `${m.mangel_nr || ''} — ${m.titel || 'Mangel'}`; let fotos = []; try { fotos = JSON.parse(m.fotos || '[]'); } catch {} let fotosNach = []; try { fotosNach = JSON.parse(m.fotos_nach || '[]'); } catch {} body.innerHTML = `
${m.status} Schwere: ${m.schwere || 'mittel'} ${m.frist ? `Frist: ${m.frist}` : ''}
Projekt: ${projekt ? escapeHtml(projekt.name) : '-'}
Gewerk: ${escapeHtml(m.gewerk || '-')}
Ort: ${escapeHtml(m.ort || '-')}
Verantwortlich: ${escapeHtml(m.verantwortlich || '-')}
Erstellt am: ${(m.created_at || '').substring(0, 10)}
Erstellt von: ${escapeHtml(m.erstellt_von || '-')}
${m.behoben_am ? `
Behoben am: ${m.behoben_am}
` : ''}
${m.beschreibung ? `
Beschreibung:

${escapeHtml(m.beschreibung)}

` : ''} ${m.kommentar ? `
Kommentar:

${escapeHtml(m.kommentar)}

` : ''}
Status ändern: ${['offen','in_arbeit','behoben','abgenommen'].map(s => `` ).join('')}
`; modal.style.display = 'flex'; } catch (e) { showToast('Fehler: ' + e.message, 'error'); } } async function changeMangelStatus(id, newStatus) { try { await apiCall('/maengel/' + id, 'PATCH', { status: newStatus }); showToast(`Status auf "${newStatus}" geändert`, 'success'); closeModal('mangelDetailModal'); loadMaengelPage(); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } } function showCreateMangelModal() { document.getElementById('createMangelModal').style.display = 'flex'; } async function saveNewMangel() { const titel = document.getElementById('newMangelTitel')?.value?.trim(); const baustelleId = document.getElementById('newMangelBaustelle')?.value; if (!titel || !baustelleId) { showToast('Titel und Baustelle sind erforderlich', 'error'); return; } try { await apiCall('/maengel', 'POST', { titel, baustelle_id: baustelleId, beschreibung: document.getElementById('newMangelBeschreibung')?.value || '', gewerk: document.getElementById('newMangelGewerk')?.value || '', schwere: document.getElementById('newMangelSchwere')?.value || 'mittel', frist: document.getElementById('newMangelFrist')?.value || '', ort: document.getElementById('newMangelOrt')?.value || '', verantwortlich: document.getElementById('newMangelVerantwortlich')?.value || '' }); showToast('Mangel angelegt', 'success'); closeModal('createMangelModal'); loadMaengelPage(); } catch (e) { showToast('Fehler: ' + e.message, 'error'); } } // ===== ABNAHMEN ===== async function loadAbnahmen() { try { const resp = await apiCall('/abnahmen'); const abnahmen = toArr(resp); document.getElementById('abnahmenCountKpi').textContent = abnahmen.length; const offen = abnahmen.filter(a => a.status === 'offen').length; document.getElementById('abnahmenOpenKpi').textContent = offen; const accepted = abnahmen.filter(a => a.status === 'abgenommen').length; document.getElementById('abnahmenAcceptedKpi').textContent = accepted; const defects = abnahmen.filter(a => a.status === 'mit_maengeln').length; document.getElementById('abnahmenDefectsKpi').textContent = defects; populateBaustelleDropdown('abnahmenBaustelleFilter'); renderAbnahmenTable(abnahmen); } catch (err) { console.error('Error loading Abnahmen:', err); showToast('Fehler beim Laden der Abnahmen', 'error'); } } function renderAbnahmenTable(abnahmen) { const tbody = document.getElementById('abnahmenList'); tbody.innerHTML = abnahmen.map(a => ` ${formatDate(a.datum)} ${a.baustelle_name || '-'} ${a.typ || 'Abnahme'} ${a.gewerk || '-'} ${(a.ergebnis || 'offen').replace(/_/g, ' ')} ${a.maengel_count || 0} ${(a.status || 'offen').charAt(0).toUpperCase() + (a.status || 'offen').slice(1)} `).join(''); } function showNewAbnahmeModal() { openModal('Neue Abnahme', `
`, async () => { const baustelleId = document.getElementById('newAbnahmeBaustelle').value; const typ = document.getElementById('newAbnahmeTyp').value; const datum = document.getElementById('newAbnahmeDatum').value; const gewerk = document.getElementById('newAbnahmeGewerk').value; if (!baustelleId || !datum) { showToast('Bitte Pflichtfelder ausfüllen', 'warning'); return; } try { await apiCall('/abnahmen', 'POST', { baustelle_id: baustelleId, typ, datum, gewerk }); showToast('Abnahme erstellt', 'success'); loadAbnahmen(); } catch (err) { showToast('Fehler beim Erstellen der Abnahme', 'error'); } }); } async function editAbnahme(id) { try { const resp = await apiCall('/abnahmen/' + id); const abnahme = toObj(resp); openModal('Abnahme bearbeiten', `
`, async () => { try { await apiCall('/abnahmen/' + id, 'PATCH', { typ: document.getElementById('editAbnahmeTyp').value, datum: document.getElementById('editAbnahmeDatum').value, gewerk: document.getElementById('editAbnahmeGewerk').value, ergebnis: document.getElementById('editAbnahmeErgebnis').value, status: document.getElementById('editAbnahmeStatus').value }); showToast('Abnahme aktualisiert', 'success'); loadAbnahmen(); } catch (err) { showToast('Fehler beim Speichern', 'error'); } }); } catch (err) { showToast('Fehler beim Laden der Abnahme', 'error'); } } async function deleteAbnahme(id) { if (confirm('Abnahme wirklich löschen?')) { try { await apiCall('/abnahmen/' + id, 'DELETE'); showToast('Abnahme gelöscht', 'success'); loadAbnahmen(); } catch (err) { showToast('Fehler beim Löschen', 'error'); } } } // ===== PROTOKOLLE ===== async function loadProtokolle() { try { const resp = await apiCall('/protokolle'); const protokolle = toArr(resp); // Calculate KPIs const gesamt = protokolle.length; let openTodos = 0; protokolle.forEach(p => { const tagesordnung = JSON.parse(p.tagesordnung || '[]'); openTodos += tagesordnung.filter(t => t.status === 'offen').length; }); const now = new Date(); const thisWeek = protokolle.filter(p => { const d = new Date(p.datum); const weekAgo = new Date(now); weekAgo.setDate(weekAgo.getDate() - 7); return d >= weekAgo && d <= now; }).length; const typCounts = {}; protokolle.forEach(p => { const typ = p.typ || 'unbekannt'; typCounts[typ] = (typCounts[typ] || 0) + 1; }); const typDist = Object.entries(typCounts).map(([k, v]) => `${k}: ${v}`).join(', '); // Render KPI Cards const kpiHtml = `
Gesamt Protokolle
${gesamt}
Diese Woche
${thisWeek}
Offene Aufgaben
${openTodos}
Typ-Verteilung
${typDist || 'keine'}
`; // Render Table const tableHtml = ` ${protokolle.map(p => { const typClass = { 'baustellenbesprechung': 'badge-info', 'abnahmeprotokoll': 'badge-success', 'sicherheitsbegehung': 'badge-warning' }[p.typ] || 'badge-secondary'; const typLabel = p.typ || 'Protokoll'; const teilnehmer = (JSON.parse(p.teilnehmer || '[]')).length || 0; const aufgaben = (JSON.parse(p.tagesordnung || '[]')).length || 0; return ` `; }).join('')}
Titel Typ Datum Ort Teilnehmer Aufgaben Status
${p.titel || '-'} ${typLabel} ${formatDate(p.datum)} ${p.ort || '-'} ${teilnehmer} ${aufgaben}
`; // Set KPI values document.getElementById('protokolleCountKpi').textContent = gesamt; document.getElementById('protokolleTodosKpi').textContent = openTodos; document.getElementById('protokolleMonthKpi').textContent = thisWeek; document.getElementById('protokolleProjectsKpi').textContent = Object.keys(typCounts).length; const container = document.getElementById('protokolleList').parentElement; container.innerHTML = kpiHtml + '
' + tableHtml + '
'; populateProjektDropdown('protokolleProjektFilter'); } catch (err) { console.error('Error loading Protokolle:', err); showToast('Fehler beim Laden der Protokolle', 'error'); } } function renderProtokolleTable(protokolle) { const tbody = document.getElementById('protokolleList'); tbody.innerHTML = protokolle.map(p => { const todos = JSON.parse(p.todos || '[]'); const openTodos = todos.filter(t => t.status === 'offen').length; return ` ${formatDate(p.datum)} ${p.titel || '-'} ${p.typ || 'Protokoll'} ${p.projekt_name || '-'} ${openTodos} ${formatDate(p.naechster_termin)} `; }).join(''); } function showNewProtokollModal() { openModal('Neues Protokoll', `
`, async () => { const projektId = document.getElementById('newProtokollProjekt').value; const titel = document.getElementById('newProtokollTitel').value; const typ = document.getElementById('newProtokollTyp').value; const datum = document.getElementById('newProtokollDatum').value; const ort = document.getElementById('newProtokollOrt').value; if (!projektId || !titel || !datum) { showToast('Bitte Pflichtfelder ausfüllen', 'warning'); return; } try { await apiCall('/protokolle', 'POST', { projekt_id: projektId, titel, typ, datum, ort }); showToast('Protokoll erstellt', 'success'); loadProtokolle(); } catch (err) { showToast('Fehler beim Erstellen des Protokolls', 'error'); } }); } async function editProtokoll(id) { try { const resp = await apiCall('/protokolle/' + id); const protokoll = toObj(resp); openModal('Protokoll bearbeiten', `
`, async () => { try { await apiCall('/protokolle/' + id, 'PATCH', { titel: document.getElementById('editProtokollTitel').value, typ: document.getElementById('editProtokollTyp').value, datum: document.getElementById('editProtokollDatum').value, ort: document.getElementById('editProtokollOrt').value, notizen: document.getElementById('editProtokollNotizen').value }); showToast('Protokoll aktualisiert', 'success'); loadProtokolle(); } catch (err) { showToast('Fehler beim Speichern', 'error'); } }); } catch (err) { showToast('Fehler beim Laden des Protokolls', 'error'); } } async function deleteProtokoll(id) { if (confirm('Protokoll wirklich löschen?')) { try { await apiCall('/protokolle/' + id, 'DELETE'); showToast('Protokoll gelöscht', 'success'); loadProtokolle(); } catch (err) { showToast('Fehler beim Löschen', 'error'); } } } // ===== CASHFLOW ===== async function loadCashflow() { try { const resp = await apiCall('/cashflow'); const transactions = toArr(resp); // Calculate KPIs const income = transactions.filter(t => t.typ === 'einnahme').reduce((sum, t) => sum + (parseFloat(t.betrag) || 0), 0); const expenses = transactions.filter(t => t.typ === 'ausgabe').reduce((sum, t) => sum + (parseFloat(t.betrag) || 0), 0); const saldo = income - expenses; const count = transactions.length; // Render KPI Cards const kpiHtml = `
Einnahmen
${formatCurrency(income)}
Ausgaben
${formatCurrency(expenses)}
Saldo
${formatCurrency(saldo)}
Einträge
${count}
`; // Render Table const tableHtml = ` ${transactions.map(t => { const isIncome = t.typ === 'einnahme'; const betragColor = isIncome ? 'var(--color-accent-green)' : 'var(--color-error)'; const betragIcon = isIncome ? '▲' : '▼'; const typLabel = isIncome ? 'Einnahme' : 'Ausgabe'; const typClass = isIncome ? 'badge-success' : 'badge-error'; return ` `; }).join('')}
Datum Projekt Kategorie Typ Betrag Beschreibung Status
${formatDate(t.datum)} ${t.projekt || '-'} ${t.kategorie || '-'} ${typLabel} ${betragIcon} ${formatCurrency(Math.abs(parseFloat(t.betrag) || 0))} ${t.beschreibung || '-'}
`; // Set KPI values document.getElementById('cashflowIncomeKpi').textContent = formatCurrency(income); document.getElementById('cashflowExpensesKpi').textContent = formatCurrency(expenses); document.getElementById('cashflowBalanceKpi').textContent = formatCurrency(saldo); document.getElementById('cashflowForecastKpi').textContent = formatCurrency(saldo * 6); const container = document.getElementById('cashflowList').parentElement; container.innerHTML = kpiHtml + '
' + tableHtml + '
'; populateProjektDropdown('cashflowProjektFilter'); renderCashflowChart(transactions); } catch (err) { console.error('Error loading Cashflow:', err); showToast('Fehler beim Laden des Cashflow', 'error'); } } function renderCashflowTable(transactions) { const tbody = document.getElementById('cashflowList'); tbody.innerHTML = transactions.map(t => ` ${formatDate(t.datum)} ${t.typ === 'einnahme' ? 'Einnahme' : 'Ausgabe'} ${t.kategorie || '-'} ${formatCurrency(parseFloat(t.betrag) || 0)} ${t.beschreibung || '-'} ${t.referenz || '-'} `).join(''); } function renderCashflowChart(transactions) { const months = {}; transactions.forEach(t => { const date = new Date(t.datum); const key = date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0'); if (!months[key]) months[key] = 0; const amount = parseFloat(t.betrag) || 0; months[key] += t.typ === 'einnahme' ? amount : -amount; }); const chartHtml = Object.entries(months).slice(-6).map(([month, amount]) => { const height = Math.abs(amount) / 10000; return `
${month}
`; }).join(''); document.getElementById('cashflowChart').innerHTML = chartHtml || '
Keine Daten
'; } function showNewCashflowModal() { openModal('Neue Transaktion', `
`, async () => { const typ = document.getElementById('newCashflowTyp').value; const kategorie = document.getElementById('newCashflowKategorie').value; const betrag = document.getElementById('newCashflowBetrag').value; const datum = document.getElementById('newCashflowDatum').value; if (!typ || !betrag || !datum) { showToast('Bitte Pflichtfelder ausfüllen', 'warning'); return; } try { await apiCall('/cashflow', 'POST', { typ, kategorie, betrag: parseFloat(betrag), datum, beschreibung: document.getElementById('newCashflowBeschreibung').value }); showToast('Transaktion erstellt', 'success'); loadCashflow(); } catch (err) { showToast('Fehler beim Erstellen der Transaktion', 'error'); } }); } async function editCashflow(id) { try { const resp = await apiCall('/cashflow/' + id); const cf = toObj(resp); openModal('Transaktion bearbeiten', `
`, async () => { try { await apiCall('/cashflow/' + id, 'PATCH', { typ: document.getElementById('editCashflowTyp').value, kategorie: document.getElementById('editCashflowKategorie').value, betrag: parseFloat(document.getElementById('editCashflowBetrag').value), datum: document.getElementById('editCashflowDatum').value, beschreibung: document.getElementById('editCashflowBeschreibung').value }); showToast('Transaktion aktualisiert', 'success'); loadCashflow(); } catch (err) { showToast('Fehler beim Speichern', 'error'); } }); } catch (err) { showToast('Fehler beim Laden der Transaktion', 'error'); } } async function deleteCashflow(id) { if (confirm('Transaktion wirklich löschen?')) { try { await apiCall('/cashflow/' + id, 'DELETE'); showToast('Transaktion gelöscht', 'success'); loadCashflow(); } catch (err) { showToast('Fehler beim Löschen', 'error'); } } } // Helper functions async function populateProjektDropdown(elementId) { try { const resp = await apiCall('/projects'); const projects = resp || []; const select = document.getElementById(elementId); if (select) { select.innerHTML = '' + projects.map(p => ``).join(''); } } catch (err) { console.error('Error loading projects for dropdown:', err); } } async function populateKundeDropdown(elementId) { try { const resp = await apiCall('/kunden'); const kunden = toArr(resp); const select = document.getElementById(elementId); if (select) { select.innerHTML = '' + kunden.map(k => ``).join(''); } } catch (err) { console.error('Error loading kunden for dropdown:', err); } } async function populateBaustelleDropdown(elementId) { try { const resp = await apiCall('/baustellen'); const baustellen = resp || []; const select = document.getElementById(elementId); if (select) { select.innerHTML = '' + baustellen.map(b => ``).join(''); } } catch (err) { console.error('Error loading baustellen for dropdown:', err); } } // ===== NEW FUNCTIONS FOR P01-P05 & P06-P15 ===== // P01: Angebote Positionen async function loadAngebotePositionen() { showToast('Angebote-Positionen Modul geladen', 'info'); } function switchPositionenTab(tab) { document.querySelectorAll('[id$="Tab"]').forEach(el => el.style.display = 'none'); document.getElementById(tab + 'Tab').style.display = 'block'; } async function createAngebotFromLV(id) { showToast('KI erstellt Angebot aus LV...', 'info'); } async function optimizeAngebotKI(id) { showToast('KI optimiert Positionen...', 'info'); } async function downloadAngebotPDF(id) { try { window.open('/api/v1/angebote/' + id + '/pdf', '_blank'); showToast('PDF wird heruntergeladen...', 'success'); } catch (err) { showToast('Fehler beim Download', 'error'); } } async function openEmailDialog(id) { const modal = document.getElementById('emailModalOverlay'); if (!modal) { const div = document.createElement('div'); div.id = 'emailModalOverlay'; div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;z-index:1000;align-items:center;justify-content:center;'; document.body.appendChild(div); } document.getElementById('emailModalOverlay').style.display = 'flex'; } function closeEmailDialog() { const modal = document.getElementById('emailModalOverlay'); if (modal) modal.style.display = 'none'; } async function createShareLink(id) { try { const resp = await apiCall('/angebote/' + id + '/share', 'POST'); const shareLink = resp.share_link || resp.link || ''; openModal('Share-Link erstellt', `

Verwende diesen Link um das Angebot zu teilen:

`, () => { closeModal(); }); showToast('Share-Link erstellt', 'success'); } catch (err) { showToast('Fehler beim Erstellen des Share-Links', 'error'); } } async function addPosition(id) { showToast('Position hinzugefügt', 'success'); } async function saveSMTPConfig() { try { const smtpHost = document.getElementById('smtpHost')?.value || ''; const smtpPort = document.getElementById('smtpPort')?.value || ''; const smtpUser = document.getElementById('smtpUser')?.value || ''; const smtpPassword = document.getElementById('smtpPassword')?.value || ''; const smtpFromEmail = document.getElementById('smtpFromEmail')?.value || ''; await apiCall('/smtp/config', 'POST', { host: smtpHost, port: parseInt(smtpPort) || 587, user: smtpUser, password: smtpPassword, from_email: smtpFromEmail }); showToast('SMTP-Konfiguration gespeichert', 'success'); } catch (err) { showToast('Fehler beim Speichern der SMTP-Konfiguration', 'error'); } } async function sendAngebotEmail(id) { showToast('Email wird versendet...', 'info'); } function closeShareLinkModal() { const modal = document.getElementById('shareLinkModal'); if (modal) modal.style.display = 'none'; } function copyShareLink() { const input = document.getElementById('shareLinkInput'); if (input) { input.select(); document.execCommand('copy'); showToast('Link kopiert!', 'success'); } } // P04: Nachträge Genehmigung async function loadNachtraegeApproval() { try { const approvalResp = await apiCall('/nachtraege?status=eingereicht'); const pendingApprovals = toArr(approvalResp); const pipelineHtml = `
Eingereicht
Prüfung
Genehmigt
`; document.getElementById('approvalPipelineContainer').innerHTML = pipelineHtml; const pendingHtml = pendingApprovals && pendingApprovals.length > 0 ? pendingApprovals.map(item => `

${item.titel || 'Nachtrag'}

Betrag: ${formatCurrency(item.betrag || 0)}

${item.status || 'eingereicht'}

${item.beschreibung || ''}

`).join('') : '

Keine ausstehenden Genehmigungen

'; document.getElementById('pendingApprovalsContainer').innerHTML = pendingHtml; showToast('Genehmigungsprozess geladen', 'success'); } catch (err) { showToast('Fehler beim Laden des Genehmigungsprozesses', 'error'); } } async function approveNachtrag(id) { openModal('Nachtrag genehmigen', `
`, async () => { try { const comment = document.getElementById('approvalComment').value; await apiCall('/nachtraege/' + id + '/approvals', 'POST', { status: 'genehmigt', kommentar: comment }); showToast('Nachtrag genehmigt', 'success'); closeModal(); loadNachtraegeApproval(); } catch (err) { showToast('Fehler beim Genehmigen', 'error'); } }); } async function rejectNachtrag(id) { openModal('Nachtrag ablehnen', `
`, async () => { try { const comment = document.getElementById('rejectionComment').value; await apiCall('/nachtraege/' + id + '/approvals', 'POST', { status: 'abgelehnt', kommentar: comment }); showToast('Nachtrag abgelehnt', 'success'); closeModal(); loadNachtraegeApproval(); } catch (err) { showToast('Fehler beim Ablehnen', 'error'); } }); } // P05: VOB & KI-Detection async function loadVobKiDetection() { try { const resp = await apiCall('/nachtraege'); const nachtraege = toArr(resp); const complianceCards = [ { title: 'Konform', count: nachtraege.filter(n => n.vob_status === 'konform').length, color: 'var(--color-accent-green)' }, { title: 'Pruefung erforderlich', count: nachtraege.filter(n => n.vob_status === 'pruefung').length, color: 'var(--color-warning)' }, { title: 'Nicht konform', count: nachtraege.filter(n => n.vob_status === 'nicht_konform').length, color: 'var(--color-error)' } ]; const html = complianceCards.map(card => `

${card.title}

${card.count}

`).join(''); document.getElementById('vobComplianceContainer').innerHTML = html; // Budget tracking const totalContract = nachtraege.reduce((sum, n) => sum + (n.urvertrag_betrag || 0), 0); const totalNachtraege = nachtraege.reduce((sum, n) => sum + (n.betrag || 0), 0); const percentage = totalContract > 0 ? (totalNachtraege / totalContract) * 100 : 0; const budgetHtml = `
Urvertrag: ${formatCurrency(totalContract)} Nachtraege: ${formatCurrency(totalNachtraege)} (${percentage.toFixed(1)}%)
`; document.getElementById('budgetTrackingContainer').innerHTML = budgetHtml; showToast('VOB-Compliance Daten geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der VOB-Daten', 'error'); } } async function loadVOBCompliance() { loadVobKiDetection(); } async function triggerKIAnalyse(id) { try { showToast('KI-Analyse wird durchgeführt...', 'info'); const resp = await apiCall('/nachtraege/' + id + '/ki-detection', 'POST'); const results = toObj(resp); openModal('KI-Analyse Ergebnisse', `
${results.detected_issues && results.detected_issues.length > 0 ? '
' + 'Erkannte Probleme:' + '
' : '
Keine Probleme erkannt
' } ${results.recommendations && results.recommendations.length > 0 ? '
' + 'Empfehlungen:' + '
' : '' }

Analyse durchgeführt mit KI-Modell: ${results.model || 'Standard'}

`, () => { closeModal(); }); showToast('KI-Analyse abgeschlossen', 'success'); } catch (err) { showToast('Fehler bei der KI-Analyse', 'error'); } } // P06: Abnahmen-Fotos async function loadAbnahmenFotos() { try { const resp = await apiCall('/abnahmen'); const abnahmen = toArr(resp); const select = document.getElementById('p06AbnahmeSelect'); if (select) { select.innerHTML = '' + abnahmen.map(a => ``).join(''); } document.getElementById('p06KpiTotal').textContent = abnahmen.length; document.getElementById('p06KpiPhotos').textContent = abnahmen.reduce((sum, a) => sum + (a.fotos_count || 0), 0); document.getElementById('p06KpiOffline').textContent = abnahmen.filter(a => a.status === 'offline').length; showToast('Abnahmen geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Abnahmen', 'error'); } } async function p06LoadPhotos() { try { const selectedId = document.getElementById('p06AbnahmeSelect').value; if (!selectedId) { showToast('Bitte Abnahme wählen', 'warning'); return; } const resp = await apiCall('/abnahmen/' + selectedId + '/fotos'); const fotos = toArr(resp); const gallery = document.getElementById('p06PhotoGallery'); if (fotos.length > 0) { gallery.innerHTML = fotos.map(foto => `

${formatDate(foto.datum)}

`).join(''); } else { gallery.innerHTML = '
Keine Fotos vorhanden
'; } showToast('Fotos geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Fotos', 'error'); } } async function p06OpenCamera() { const input = document.getElementById('p06CameraInput'); if (input) input.click(); } async function p06HandlePhotoUpload() { try { const selectedId = document.getElementById('p06AbnahmeSelect').value; if (!selectedId) { showToast('Bitte Abnahme wählen', 'warning'); return; } const input = document.getElementById('p06CameraInput'); if (!input || !input.files || input.files.length === 0) { showToast('Bitte Foto auswählen', 'warning'); return; } const file = input.files[0]; const reader = new FileReader(); reader.onload = async function() { try { const base64 = reader.result.split(',')[1]; await apiCall('/abnahmen/' + selectedId + '/fotos/upload', 'POST', { filename: file.name, data: base64 }); showToast('Foto hochgeladen', 'success'); input.value = ''; p06LoadPhotos(); } catch (err) { showToast('Fehler beim Hochladen des Fotos', 'error'); } }; reader.readAsDataURL(file); } catch (err) { showToast('Fehler bei der Foto-Verarbeitung', 'error'); } } // P07: Abnahmen-PDF async function loadAbnahmenPdf() { try { const resp = await apiCall('/abnahmen'); const abnahmen = toArr(resp); const select = document.getElementById('p07AbnahmeSelect'); if (select) { select.innerHTML = '' + abnahmen.map(a => ``).join(''); } document.getElementById('p07KpiSigned').textContent = abnahmen.filter(a => a.signiert === true).length; document.getElementById('p07KpiDefects').textContent = abnahmen.reduce((sum, a) => sum + (a.maengel_count || 0), 0); document.getElementById('p07KpiPdfs').textContent = abnahmen.filter(a => a.pdf_generated === true).length; showToast('Abnahmen geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Abnahmen', 'error'); } } async function p07LoadAbnahmeData() { try { const selectedId = document.getElementById('p07AbnahmeSelect').value; if (!selectedId) { showToast('Bitte Abnahme wählen', 'warning'); return; } const resp = await apiCall('/abnahmen/' + selectedId); const abnahme = toObj(resp); showToast('Abnahmedaten geladen: ' + (abnahme.titel || 'Abnahme'), 'success'); } catch (err) { showToast('Fehler beim Laden der Abnahmedaten', 'error'); } } async function p07ClearSignatureAG() { const canvas = document.getElementById('p07SignaturePadAG'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } } async function p07ClearSignatureAN() { const canvas = document.getElementById('p07SignaturePadAN'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } } async function p07GeneratePDF() { try { const selectedId = document.getElementById('p07AbnahmeSelect').value; if (!selectedId) { showToast('Bitte Abnahme wählen', 'warning'); return; } showToast('PDF wird generiert...', 'info'); const resp = await apiCall('/abnahmen/' + selectedId + '/pdf/generate', 'POST'); const pdfData = toObj(resp); if (pdfData.pdf_url) { window.open(pdfData.pdf_url, '_blank'); showToast('PDF generiert und heruntergeladen', 'success'); } else { showToast('PDF wurde generiert', 'success'); } } catch (err) { showToast('Fehler beim Generieren des PDFs', 'error'); } } // P08: Protokolle-Audio async function loadProtokollAudio() { try { const resp = await apiCall('/protokolle'); const protokolle = toArr(resp); const select = document.getElementById('p08ProtokollSelect'); if (select) { select.innerHTML = '' + protokolle.map(p => ``).join(''); } document.getElementById('p08KpiTotal').textContent = protokolle.length; document.getElementById('p08KpiAudios').textContent = protokolle.reduce((sum, p) => sum + (p.audios_count || 0), 0); document.getElementById('p08KpiTasks').textContent = protokolle.reduce((sum, p) => sum + (p.aufgaben_count || 0), 0); showToast('Protokolle geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Protokolle', 'error'); } } async function p08LoadProtokollData() { try { const selectedId = document.getElementById('p08ProtokollSelect').value; if (!selectedId) { showToast('Bitte Protokoll wählen', 'warning'); return; } const resp = await apiCall('/protokolle/' + selectedId + '/aufgaben'); const aufgaben = toArr(resp); const taskList = document.getElementById('p08TaskList'); if (aufgaben.length > 0) { taskList.innerHTML = aufgaben.map(task => ` ${task.beschreibung || 'Aufgabe'} ${task.status || 'offen'} `).join(''); } else { taskList.innerHTML = 'Keine Aufgaben'; } showToast('Protokolldaten geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Protokolldaten', 'error'); } } async function p08HandleDrop(event) { event.preventDefault(); const zone = document.getElementById('p08AudioDropzone'); if (zone) zone.classList.remove('drag-over'); p08HandleAudioUpload(); } async function p08HandleDragOver(event) { event.preventDefault(); const zone = document.getElementById('p08AudioDropzone'); if (zone) zone.classList.add('drag-over'); } async function p08HandleDragLeave(event) { const zone = document.getElementById('p08AudioDropzone'); if (zone) zone.classList.remove('drag-over'); } async function p08HandleAudioUpload() { try { const selectedId = document.getElementById('p08ProtokollSelect').value; if (!selectedId) { showToast('Bitte Protokoll wählen', 'warning'); return; } const input = document.getElementById('p08AudioInput'); if (!input || !input.files || input.files.length === 0) { showToast('Bitte Audiodatei wählen', 'warning'); return; } const file = input.files[0]; const reader = new FileReader(); reader.onload = async function() { try { const base64 = reader.result.split(',')[1]; await apiCall('/protokolle/' + selectedId + '/audio/upload', 'POST', { filename: file.name, data: base64 }); showToast('Audio hochgeladen', 'success'); input.value = ''; } catch (err) { showToast('Fehler beim Hochladen der Audiodatei', 'error'); } }; reader.readAsDataURL(file); } catch (err) { showToast('Fehler bei der Audio-Verarbeitung', 'error'); } } async function p08AddTask() { try { const selectedId = document.getElementById('p08ProtokollSelect').value; if (!selectedId) { showToast('Bitte Protokoll wählen', 'warning'); return; } const taskInput = document.getElementById('p08TaskInput'); const beschreibung = taskInput ? taskInput.value : ''; if (!beschreibung) { showToast('Bitte Aufgabenbeschreibung eingeben', 'warning'); return; } await apiCall('/protokolle/' + selectedId + '/aufgaben', 'POST', { beschreibung: beschreibung }); if (taskInput) taskInput.value = ''; showToast('Aufgabe hinzugefügt', 'success'); p08LoadProtokollData(); } catch (err) { showToast('Fehler beim Hinzufügen der Aufgabe', 'error'); } } // P09: Protokolle-Serien async function loadProtokollSerien() { try { const resp = await apiCall('/protokolle/serien'); const serien = toArr(resp); const html = `

Besprechungsserien

${serien && serien.length > 0 ? '' + '' + '' + '' + '' + '' + '' + '' + serien.map(s => '' + '' + '' + '' + '' + '' ).join('') + '
TitelTypIntervallNächster Termin
' + (s.titel || '-') + '' + (s.typ || '-') + '' + (s.intervall || '-') + '' + formatDate(s.naechster_termin) + '
' : '

Keine Besprechungsserien vorhanden

' }
`; const container = document.getElementById('protokolle-serien-content') || document.createElement('div'); container.innerHTML = html; if (!document.getElementById('protokolle-serien-content')) { container.id = 'protokolle-serien-content'; document.body.appendChild(container); } showToast('Besprechungsserien geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Besprechungsserien', 'error'); } } async function p09CreateSerie() { openModal('Besprechungsserie erstellen', `
`, async () => { const titel = document.getElementById('p09SerieTitel').value; const typ = document.getElementById('p09SerieTyp').value; const intervall = document.getElementById('p09SerieIntervall').value; const projekt_id = document.getElementById('p09SerieProjekt').value; if (!titel || !intervall) { showToast('Bitte Pflichtfelder ausfüllen', 'warning'); return; } try { await apiCall('/protokolle/serien', 'POST', { titel, typ, intervall, projekt_id }); showToast('Besprechungsserie erstellt', 'success'); loadProtokollSerien(); } catch (err) { showToast('Fehler beim Erstellen der Serie', 'error'); } }); populateProjektDropdown('p09SerieProjekt'); } // P10: Cashflow-Erweitert async function loadCashflowErweitert() { try { const sollIstResp = await apiCall('/cashflow/soll-ist'); const szenarien = await apiCall('/cashflow/szenarien'); const sollIst = toObj(sollIstResp); const szenarien_data = toArr(szenarien); const html = `

Soll/Ist Analyse

Soll

${formatCurrency(sollIst.soll || 0)}

Ist

${formatCurrency(sollIst.ist || 0)}

Differenz

${formatCurrency((sollIst.soll || 0) - (sollIst.ist || 0))}

Abweichung %

${((((sollIst.ist || 0) / (sollIst.soll || 1)) - 1) * 100).toFixed(1)}%

Szenarien

${szenarien_data && szenarien_data.length > 0 ? '' + '' + '' + '' + '' + '' + '' + szenarien_data.map(s => '' + '' + '' + '' + '' ).join('') + '
SzenarioBetragWahrscheinlichkeit
' + (s.name || '-') + '' + formatCurrency(s.betrag || 0) + '' + ((s.wahrscheinlichkeit || 0) * 100).toFixed(0) + '%
' : '

Keine Szenarien vorhanden

' }
`; const container = document.getElementById('cashflow-erweitert-content') || document.createElement('div'); container.innerHTML = html; if (!document.getElementById('cashflow-erweitert-content')) { container.id = 'cashflow-erweitert-content'; document.body.appendChild(container); } showToast('Cashflow-Daten geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Cashflow-Daten', 'error'); } } // P11: Liquidität & Mahnwesen async function loadLiquiditaetMahnwesen() { try { const resp = await apiCall('/mahnungen'); const mahnungen = toArr(resp); const html = `

Zahlungsmahnung & Liquidität

${mahnungen && mahnungen.length > 0 ? '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + mahnungen.map(m => '' + '' + '' + '' + '' + '' + '' + '' ).join('') + '
RechnungsnummerKundeBetragFällig seitMahnungstufeStatus
' + (m.rechnung_nummer || '-') + '' + (m.kunde_name || '-') + '' + formatCurrency(m.betrag || 0) + '' + formatDate(m.faellig_seit) + '' + (m.mahnungstufe || 1) + '. Mahnung' + (m.status || 'offen') + '
' : '

Keine ausstehenden Mahnungen

' }
`; const container = document.getElementById('liquiditaet-mahnwesen-content') || document.createElement('div'); container.innerHTML = html; if (!document.getElementById('liquiditaet-mahnwesen-content')) { container.id = 'liquiditaet-mahnwesen-content'; document.body.appendChild(container); } showToast('Mahnungen geladen', 'success'); } catch (err) { showToast('Fehler beim Laden der Mahnungen', 'error'); } } async function autoGenerateReminders() { try { showToast('Mahnungen werden automatisch generiert...', 'info'); await apiCall('/mahnungen/auto-generate', 'POST'); showToast('Mahnungen generiert', 'success'); loadLiquiditaetMahnwesen(); } catch (err) { showToast('Fehler beim Generieren der Mahnungen', 'error'); } } // ═══ P11B: BAUTAGEBUCH ═══ async function loadBautagebuch() { const filterBaustelle = document.getElementById('bautagebuchBaustelleFilter')?.value || ''; let url = '/bautagebuch'; if (filterBaustelle) url += `?baustelle_id=${filterBaustelle}`; try { const [res, bsRes] = await Promise.all([ apiCall(url), apiCall('/baustellen') ]); const entries = Array.isArray(res) ? res : (res?.data || []); const baustellen = Array.isArray(bsRes) ? bsRes : (bsRes?.data || []); // Populate baustelle filter const filterEl = document.getElementById('bautagebuchBaustelleFilter'); if (filterEl && filterEl.options.length <= 1) { baustellen.forEach(b => { const opt = document.createElement('option'); opt.value = b.id; opt.textContent = b.name || b.adresse || b.id; filterEl.appendChild(opt); }); } // KPIs const today = new Date().toISOString().split('T')[0]; const thisWeek = entries.filter(e => { const d = new Date(e.datum); const now = new Date(); const diff = (now - d) / (1000*60*60*24); return diff <= 7; }); const totalMA = entries.reduce((s,e) => s + (parseInt(e.mitarbeiter_anzahl)||0), 0); const vorkommnisse = entries.filter(e => e.besondere_vorkommnisse && e.besondere_vorkommnisse.trim() !== ''); document.getElementById('bautagebuchKPIs').innerHTML = `
${entries.length}
Einträge Gesamt
${thisWeek.length}
Diese Woche
${totalMA}
MA-Einsätze Gesamt
${vorkommnisse.length}
Besondere Vorkommnisse
`; // Timeline-style entries if (entries.length === 0) { document.getElementById('bautagebuchContent').innerHTML = '
Noch keine Bautagebuch-Einträge vorhanden. Erstellen Sie den ersten Eintrag!
'; return; } // Sort by date descending entries.sort((a,b) => (b.datum||'').localeCompare(a.datum||'')); let html = '
'; entries.forEach(e => { const datum = e.datum ? new Date(e.datum).toLocaleDateString('de-DE', {weekday:'long', day:'2-digit', month:'long', year:'numeric'}) : '-'; const wetterIcon = {'sonnig':'☀️','bewoelkt':'☁️','regen':'🌧️','schnee':'❄️','nebel':'🌫️','sturm':'⛈️'}[e.wetter] || '🌤️'; const bsName = baustellen.find(b => b.id === e.baustelle_id)?.name || 'Unbekannt'; html += `
${datum}
📍 ${bsName}
${wetterIcon} ${e.wetter || '-'} ${e.temperatur ? e.temperatur + '°C' : ''} ⏰ ${e.arbeitsbeginn || '?'} - ${e.arbeitsende || '?'}
👷 Mitarbeiter: ${e.mitarbeiter_anzahl || 0}
🏗️ Gewerke: ${e.gewerke_aktiv || '-'}
🤝 Subunternehmer: ${e.subunternehmer || '-'}
${e.arbeiten_ausgefuehrt ? `
Ausgeführte Arbeiten:
${e.arbeiten_ausgefuehrt}
` : ''} ${e.besondere_vorkommnisse ? `
⚠️ Besondere Vorkommnisse:
${e.besondere_vorkommnisse}
` : ''} ${e.materiallieferungen ? `
📦 Lieferungen: ${e.materiallieferungen}
` : ''} ${e.besucher ? `
👤 Besucher: ${e.besucher}
` : ''} ${e.anweisungen ? `
📋 Anweisungen: ${e.anweisungen}
` : ''}
`; }); html += '
'; document.getElementById('bautagebuchContent').innerHTML = html; } catch(err) { console.error('Bautagebuch load error:', err); document.getElementById('bautagebuchContent').innerHTML = '
Fehler beim Laden des Bautagebuchs
'; } } function showNewBautagebuchModal() { const html = `
`; openModal('📓 Neuer Bautagebuch-Eintrag', html, async () => { const body = { baustelle_id: document.getElementById('btbBaustelle').value, datum: document.getElementById('btbDatum').value, wetter: document.getElementById('btbWetter').value, temperatur: document.getElementById('btbTemperatur').value, arbeitsbeginn: document.getElementById('btbBeginn').value, arbeitsende: document.getElementById('btbEnde').value, mitarbeiter_anzahl: parseInt(document.getElementById('btbMA').value) || 0, gewerke_aktiv: document.getElementById('btbGewerke').value, arbeiten_ausgefuehrt: document.getElementById('btbArbeiten').value, besondere_vorkommnisse: document.getElementById('btbVorkommnisse').value, materiallieferungen: document.getElementById('btbMaterial').value }; if (!body.baustelle_id) { showToast('Bitte Baustelle wählen', 'error'); return; } if (!body.datum) { showToast('Bitte Datum angeben', 'error'); return; } if (!body.arbeiten_ausgefuehrt) { showToast('Bitte Arbeiten beschreiben', 'error'); return; } try { await apiCall('/bautagebuch', 'POST', body); closeModal(); showToast('Bautagebuch-Eintrag erstellt'); loadBautagebuch(); } catch(err) { showToast('Fehler: ' + (err.message||err), 'error'); } }); // Populate baustelle dropdown apiCall('/baustellen').then(res => { const sel = document.getElementById('btbBaustelle'); const arr = Array.isArray(res) ? res : (res?.data || []); arr.forEach(b => { const opt = document.createElement('option'); opt.value = b.id; opt.textContent = b.name || b.adresse || b.id; sel.appendChild(opt); }); }).catch(() => {}); } async function editBautagebuch(entryId) { try { const entry = await apiCall('/bautagebuch/' + entryId); if (!entry || entry.error) { showToast('Eintrag nicht gefunden', 'error'); return; } const e = toObj(entry); const html = `
`; openModal('Bautagebuch bearbeiten', html, async () => { try { await apiCall('/bautagebuch/' + entryId, 'PATCH', { datum: document.getElementById('btbEditDatum').value, wetter: document.getElementById('btbEditWetter').value, temperatur: document.getElementById('btbEditTemp').value, mitarbeiter_anzahl: parseInt(document.getElementById('btbEditMA').value) || 0, arbeitsbeginn: document.getElementById('btbEditBeginn').value, arbeitsende: document.getElementById('btbEditEnde').value, gewerke_aktiv: document.getElementById('btbEditGewerke').value, subunternehmer: document.getElementById('btbEditSub').value, arbeiten_ausgefuehrt: document.getElementById('btbEditArbeiten').value, besondere_vorkommnisse: document.getElementById('btbEditVork').value, materiallieferungen: document.getElementById('btbEditMaterial').value }); showToast('Eintrag aktualisiert', 'success'); closeModal(); loadBautagebuch(); } catch(err) { showToast('Fehler: ' + (err.message||err), 'error'); } }); document.getElementById('modalSubmitBtn').textContent = 'Speichern'; } catch(err) { showToast('Fehler beim Laden', 'error'); } } // ═══ P12: PLANTAFEL ═══ let plantafelWeekOffset = 0; async function loadPlantafel() { const today = new Date(); today.setDate(today.getDate() + (plantafelWeekOffset * 7)); const monday = new Date(today); monday.setDate(today.getDate() - today.getDay() + 1); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); const von = monday.toISOString().split('T')[0]; const bis = sunday.toISOString().split('T')[0]; try { const data = await apiCall(`/plantafel/view?datum_von=${von}&datum_bis=${bis}`); renderPlantafelGrid(data, monday); const cap = await apiCall('/plantafel/kapazitaet'); if (cap && cap.length) { document.getElementById('plantafelRessCount').textContent = cap.length; const avg = cap.reduce((s,r) => s + (r.auslastung||0), 0) / cap.length; document.getElementById('plantafelAuslastung').textContent = Math.round(avg) + '%'; } } catch(e) { console.error('Plantafel:', e); } } function renderPlantafelGrid(data, startDate) { const grid = document.getElementById('plantafelGrid'); const days = ['Mo','Di','Mi','Do','Fr','Sa','So']; const dates = []; for (let i=0;i<7;i++) { const d=new Date(startDate); d.setDate(d.getDate()+i); dates.push(d); } let html = ''; dates.forEach((d,i) => { html += ``; }); html += ''; const ress = data.ressourcen || []; ress.forEach(r => { html += ``; dates.forEach(d => { const ds = d.toISOString().split('T')[0]; const einsaetze = (data.einsaetze||[]).filter(e => e.ressource_id === r.id && ds >= e.datum_von && ds <= e.datum_bis); const abw = (data.abwesenheiten||[]).filter(a => a.ressource_id === r.id && ds >= a.datum_von && ds <= a.datum_bis); let cell = ''; if (abw.length) cell = `
${abw[0].typ}
`; else if (einsaetze.length) einsaetze.forEach(e => { cell += `
${e.beschreibung||'Einsatz'}
`; }); html += ``; }); html += ''; }); html += '
Ressource${days[i]} ${d.getDate()}.${d.getMonth()+1}
${r.typ === 'mitarbeiter' ? '👤' : r.typ === 'fahrzeug' ? '🚗' : '⚙️'} ${r.name}${cell}
'; grid.innerHTML = html; } function navigateWeek(dir) { if(dir===0) plantafelWeekOffset=0; else plantafelWeekOffset+=dir; loadPlantafel(); } function filterRessourcen(typ) { loadPlantafel(); } function switchAbnahmenTab(tab) { document.querySelectorAll('[id^="abnahmenContent"]').forEach(el => el.style.display = 'none'); document.querySelectorAll('[id^="abnahmenTab"]').forEach(btn => { if (btn.classList.contains('tab-btn')) { btn.style.color = 'var(--text-secondary)'; btn.style.borderBottomColor = 'transparent'; } }); const tabMap = { 'abnahmen': { content: 'abnahmenContentAbnahmen', btn: 'abnahmenTabAbnahmen', load: () => loadAbnahmen() }, 'fotos': { content: 'abnahmenContentFotos', btn: 'abnahmenTabFotos', load: () => { if (typeof loadAbnahmenFotos === 'function') loadAbnahmenFotos(); } }, 'maengel': { content: 'abnahmenContentMaengel', btn: 'abnahmenTabMaengel', load: () => {} }, 'checklisten': { content: 'abnahmenContentChecklisten', btn: 'abnahmenTabChecklisten', load: () => {} } }; const t = tabMap[tab] || tabMap['abnahmen']; const contentEl = document.getElementById(t.content); const btnEl = document.getElementById(t.btn); if (contentEl) contentEl.style.display = 'block'; if (btnEl) { btnEl.style.color = 'var(--color-cyan)'; btnEl.style.borderBottomColor = 'var(--color-cyan)'; } t.load(); } function switchPlantafelTab(tab) { document.querySelectorAll('[id^="plantafelContent"]').forEach(el => el.style.display = 'none'); document.querySelectorAll('[id^="plantafelTab"]').forEach(btn => { btn.style.color = 'var(--text-secondary)'; btn.style.borderBottomColor = 'transparent'; }); if (tab === 'einsatz') { document.getElementById('plantafelContentEinsatz').style.display = 'block'; document.getElementById('plantafelTabEinsatz').style.color = 'var(--color-cyan)'; document.getElementById('plantafelTabEinsatz').style.borderBottomColor = 'var(--color-cyan)'; loadPlantafel(); } else if (tab === 'ressourcen') { document.getElementById('plantafelContentRessourcen').style.display = 'block'; document.getElementById('plantafelTabRessourcen').style.color = 'var(--color-cyan)'; document.getElementById('plantafelTabRessourcen').style.borderBottomColor = 'var(--color-cyan)'; loadPlantafelRessourcen(); } else if (tab === 'abwesenheiten') { document.getElementById('plantafelContentAbwesenheiten').style.display = 'block'; document.getElementById('plantafelTabAbwesenheiten').style.color = 'var(--color-cyan)'; document.getElementById('plantafelTabAbwesenheiten').style.borderBottomColor = 'var(--color-cyan)'; loadPlantafelAbwesenheiten(); } else if (tab === 'zeit') { document.getElementById('plantafelContentZeit').style.display = 'block'; document.getElementById('plantafelTabZeit').style.color = 'var(--color-cyan)'; document.getElementById('plantafelTabZeit').style.borderBottomColor = 'var(--color-cyan)'; loadPlantafelZeit(); } else if (tab === 'gantt') { document.getElementById('plantafelContentGantt').style.display = 'block'; document.getElementById('plantafelTabGantt').style.color = 'var(--color-cyan)'; document.getElementById('plantafelTabGantt').style.borderBottomColor = 'var(--color-cyan)'; loadGanttChart(); } else if (tab === 'monatsabschluss') { document.getElementById('plantafelContentMonatsabschluss').style.display = 'block'; document.getElementById('plantafelTabMonatsabschluss').style.color = 'var(--color-cyan)'; document.getElementById('plantafelTabMonatsabschluss').style.borderBottomColor = 'var(--color-cyan)'; populateMonthDropdown(); loadMonatsabschlussData(); } } // ═══ MONATSABSCHLUSS FUNCTIONS ═══ let currentMonatsabschlussId = null; async function loadMonatsabschlussData() { try { const monatSelect = document.getElementById('monatsabschlussMonat'); const selectedMonth = monatSelect.value; if (!selectedMonth) { document.getElementById('monatsabschlussKpis').innerHTML = '

Bitte wählen Sie einen Monat.

'; document.getElementById('monatsabschlussTableBody').innerHTML = ''; document.getElementById('monatsabschlussCloseBtn').style.display = 'none'; document.getElementById('monatsabschlussReleaseBtn').style.display = 'none'; document.getElementById('monatsabschlussPdfBtn').style.display = 'none'; return; } const result = await apiCall(`/api/v1/zeiterfassung/monatsabschluss?monat=${selectedMonth}`); const items = Array.isArray(result) ? result : (result.items || []); if (!items || items.length === 0) { document.getElementById('monatsabschlussKpis').innerHTML = '

Kein Abschluss für diesen Monat vorhanden.

'; document.getElementById('monatsabschlussTableBody').innerHTML = ''; document.getElementById('monatsabschlussCloseBtn').style.display = 'inline-block'; document.getElementById('monatsabschlussReleaseBtn').style.display = 'none'; document.getElementById('monatsabschlussPdfBtn').style.display = 'none'; return; } const abschluss = items[0]; currentMonatsabschlussId = abschluss.id; const detail = await apiCall(`/api/v1/zeiterfassung/monatsabschluss/${abschluss.id}`); const gesamtStunden = parseFloat(detail.gesamt_stunden || 0); const gesamtKosten = parseFloat(detail.gesamt_kosten || 0); const empCount = parseInt(detail.mitarbeiter_count || 0); const avgStundenProMitarbeiter = empCount > 0 ? (gesamtStunden / empCount).toFixed(2) : '0.00'; const kpisHtml = `
Gesamt-Stunden
${gesamtStunden.toFixed(2)}h
Ø pro Mitarbeiter
${avgStundenProMitarbeiter}h
Gesamtkosten
${formatCurrency(gesamtKosten)}
Mitarbeiter
${empCount}
Status
${detail.status}
`; document.getElementById('monatsabschlussKpis').innerHTML = kpisHtml; const employees = detail.employee_summary || []; const tbody = document.getElementById('monatsabschlussTableBody'); if (employees.length === 0) { tbody.innerHTML = 'Keine Mitarbeiterdaten'; } else { tbody.innerHTML = employees.map(emp => ` ${emp.name || '-'} ${(emp.total_hours || 0).toFixed(2)}h ${formatCurrency(emp.stundensatz || 0)} ${formatCurrency(emp.total_cost || 0)} `).join(''); } if (detail.status === 'abgeschlossen' || detail.status === 'offen') { document.getElementById('monatsabschlussCloseBtn').style.display = 'none'; document.getElementById('monatsabschlussReleaseBtn').style.display = 'inline-block'; document.getElementById('monatsabschlussPdfBtn').style.display = 'inline-block'; } else if (detail.status === 'freigegeben') { document.getElementById('monatsabschlussCloseBtn').style.display = 'none'; document.getElementById('monatsabschlussReleaseBtn').style.display = 'none'; document.getElementById('monatsabschlussPdfBtn').style.display = 'inline-block'; } else { document.getElementById('monatsabschlussCloseBtn').style.display = 'inline-block'; document.getElementById('monatsabschlussReleaseBtn').style.display = 'none'; document.getElementById('monatsabschlussPdfBtn').style.display = 'none'; } } catch (e) { console.error('Monatsabschluss load:', e); showToast('Fehler beim Laden der Monatsabschlüsse', 'error'); } } async function populateMonthDropdown() { const now = new Date(); const select = document.getElementById('monatsabschlussMonat'); select.innerHTML = ''; for (let i = 0; i < 12; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const monthValue = `${year}-${month}`; const monthNames = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']; const label = `${monthNames[d.getMonth()]} ${year}`; const option = document.createElement('option'); option.value = monthValue; option.textContent = label; select.appendChild(option); } } async function closeMonth() { try { const monatSelect = document.getElementById('monatsabschlussMonat'); const monat = monatSelect.value; const notizen = prompt('Notizen zum Monatsabschluss (optional):', ''); if (!monat) { showToast('Bitte wählen Sie einen Monat', 'error'); return; } if (notizen === null) return; const response = await apiCall('/api/v1/zeiterfassung/monatsabschluss', 'POST', { monat: monat, notizen: notizen || '' }); if (response.id) { showToast('Monat erfolgreich abgeschlossen', 'success'); await loadMonatsabschlussData(); } else { showToast('Fehler beim Abschließen des Monats', 'error'); } } catch (e) { console.error('Close month:', e); showToast('Fehler beim Abschließen des Monats', 'error'); } } async function releaseMonth() { try { if (!currentMonatsabschlussId) { showToast('Kein Abschluss ausgewählt', 'error'); return; } if (!confirm('Möchten Sie diesen Monatsabschluss freigeben? Dies kann nicht rückgängig gemacht werden.')) { return; } const response = await apiCall(`/api/v1/zeiterfassung/monatsabschluss/${currentMonatsabschlussId}/freigabe`, 'POST', {}); if (response.id) { showToast('Monat erfolgreich freigegeben', 'success'); await loadMonatsabschlussData(); } else { showToast('Fehler beim Freigeben des Monats', 'error'); } } catch (e) { console.error('Release month:', e); showToast('Fehler beim Freigeben des Monats', 'error'); } } async function downloadMonatsabschlussPdf() { try { if (!currentMonatsabschlussId) { showToast('Kein Abschluss ausgewählt', 'error'); return; } const response = await fetch(`/api/v1/zeiterfassung/monatsabschluss/${currentMonatsabschlussId}/pdf`, { method: 'GET', headers: { 'Content-Type': 'application/pdf' } }); if (!response.ok) { throw new Error('PDF export failed'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const monatSelect = document.getElementById('monatsabschlussMonat'); const monat = monatSelect.value || 'abschluss'; a.download = `monatsabschluss_${monat}.pdf`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); showToast('PDF erfolgreich heruntergeladen', 'success'); } catch (e) { console.error('Download PDF:', e); showToast('Fehler beim Herunterladen des PDF', 'error'); } } // ═══ P12: PLANTAFEL LOAD FUNCTIONS ═══ async function loadPlantafelRessourcen() { try { const ressourcen = await apiCall('/plantafel/ressourcen'); const mitarbeiter = (ressourcen||[]).filter(r => r.typ === 'mitarbeiter').length; const fahrzeuge = (ressourcen||[]).filter(r => r.typ === 'fahrzeug').length; const maschinen = (ressourcen||[]).filter(r => r.typ === 'maschine').length; document.getElementById('ressCountMitarbeiter').textContent = mitarbeiter; document.getElementById('ressCountFahrzeug').textContent = fahrzeuge; document.getElementById('ressCountMaschine').textContent = maschinen; const tbody = document.getElementById('ressTableBody'); if (!ressourcen || ressourcen.length === 0) { tbody.innerHTML = 'Keine Ressourcen'; return; } tbody.innerHTML = (ressourcen||[]).map(r => ` ${r.name || '-'} ${r.typ || '-'} ${r.qualifikationen || '-'} ${formatCurrency(parseFloat(r.kosten_pro_tag) || 0)} ${r.status || 'inaktiv'} ${r.verfügbar ? '✓' : '✗'} `).join(''); } catch (e) { console.error('Ressourcen:', e); showToast('Fehler beim Laden der Ressourcen', 'error'); } } async function loadPlantafelAbwesenheiten() { try { const abwesenheiten = await apiCall('/plantafel/abwesenheiten'); const today = new Date().toISOString().split('T')[0]; const weekStart = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const monthStart = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const aktiv = (abwesenheiten||[]).filter(a => a.datum_von <= today && a.datum_bis >= today).length; const woche = (abwesenheiten||[]).filter(a => a.datum_von >= weekStart && a.datum_bis <= today).length; const monat = (abwesenheiten||[]).filter(a => a.datum_von >= monthStart && a.datum_bis <= today).length; document.getElementById('abwCountAktiv').textContent = aktiv; document.getElementById('abwCountWoche').textContent = woche; document.getElementById('abwCountMonat').textContent = monat; const tbody = document.getElementById('abwTableBody'); if (!abwesenheiten || abwesenheiten.length === 0) { tbody.innerHTML = 'Keine Abwesenheiten'; return; } tbody.innerHTML = (abwesenheiten||[]).map(a => { const von = new Date(a.datum_von); const bis = new Date(a.datum_bis); const tage = Math.ceil((bis - von) / (1000 * 60 * 60 * 24)) + 1; return ` ${a.mitarbeiter_name || '-'} ${a.typ || '-'} ${formatDate(a.datum_von)} ${formatDate(a.datum_bis)} ${tage} ${a.status || 'ausstehend'} `; }).join(''); } catch (e) { console.error('Abwesenheiten:', e); showToast('Fehler beim Laden der Abwesenheiten', 'error'); } } async function loadPlantafelZeit() { try { const data = await apiCall('/zeiterfassung'); zeitData = data || []; updateZeitKpis(); renderZeitTable(); } catch (e) { showToast('Fehler beim Laden der Zeiterfassung', 'error'); } } function editRessource(ressID) { showToast('Edit Ressource ' + ressID + ' - Feature in Entwicklung', 'info'); } async function deleteAbwesenheit(abwID) { if (!confirm('Wirklich löschen?')) return; try { await apiCall(`/plantafel/abwesenheiten/${abwID}`, 'DELETE'); showToast('Abwesenheit gelöscht', 'success'); loadPlantafelAbwesenheiten(); } catch (e) { showToast('Fehler beim Löschen', 'error'); } } // ═══ GANTT CHART IMPLEMENTATION ═══ let ganttZoomLevel = 'wochen'; let ganttData = []; let ganttToday = new Date(); const gewerkFarben = { 'Elektro': '#22B8CF', 'Sanitär': '#A78BFA', 'Heizung': '#F87171', 'Maurwerk': '#D4A574', 'Tischlerei': '#8B6F47', 'Maler': '#FBBF24', 'Sonstige': '#6B7280' }; async function loadGanttChart() { try { const container = document.getElementById('ganttContainer'); const emptyState = document.getElementById('ganttEmptyState'); // Load planungen/phasen and aufgaben const [planungen, aufgaben] = await Promise.all([ apiCall('/planungen'), apiCall('/aufgaben') ]); ganttData = []; // Process phasen from planungen if (planungen && planungen.length > 0) { for (const plan of planungen) { const planDetail = await apiCall(`/planungen/${plan.id}`); if (planDetail && planDetail.phasen) { planDetail.phasen.forEach(phase => { ganttData.push({ id: phase.id, name: phase.name, type: 'phase', start_datum: phase.start_datum || plan.start_datum, end_datum: phase.end_datum || plan.end_datum, status: phase.status || 'geplant', progress: phase.fortschritt || 0, gewerk: phase.name, description: phase.beschreibung }); }); } } } // Process aufgaben with due dates if (aufgaben && aufgaben.length > 0) { aufgaben.forEach(task => { if (task.due_date) { ganttData.push({ id: task.id, name: task.title, type: 'aufgabe', start_datum: task.due_date, end_datum: task.due_date, status: task.status || 'offen', progress: task.status === 'abgeschlossen' ? 100 : 0, gewerk: task.category || 'Sonstige', description: task.description }); } }); } // Filter by gewerk if selected const gewerkFilter = document.getElementById('ganttFilterGewerk').value; const filtered = gewerkFilter ? ganttData.filter(d => d.gewerk === gewerkFilter) : ganttData; if (filtered.length === 0) { container.style.display = 'none'; emptyState.style.display = 'block'; return; } container.style.display = 'block'; emptyState.style.display = 'none'; renderGanttChart(filtered); } catch (e) { console.error('Gantt error:', e); showToast('Fehler beim Laden des Gantt-Charts', 'error'); } } function renderGanttChart(tasks) { const container = document.getElementById('ganttContainer'); if (!tasks.length) return; // Calculate date range const validTasks = tasks.filter(t => t.start_datum && t.end_datum); if (!validTasks.length) return; const dates = validTasks.flatMap(t => [new Date(t.start_datum), new Date(t.end_datum)]); const minDate = new Date(Math.min(...dates.map(d => d.getTime()))); const maxDate = new Date(Math.max(...dates.map(d => d.getTime()))); // Add 7 day buffer minDate.setDate(minDate.getDate() - 7); maxDate.setDate(maxDate.getDate() + 7); // Generate timeline columns based on zoom level const timeline = generateTimeline(minDate, maxDate, ganttZoomLevel); const colWidth = ganttZoomLevel === 'tage' ? 40 : ganttZoomLevel === 'wochen' ? 60 : 80; // Build HTML let html = `
`; html += `
`; html += `
Aufgaben
`; tasks.forEach(task => { const gewerk = task.gewerk || 'Sonstige'; const color = gewerkFarben[gewerk] || '#6B7280'; html += `
${task.name}
`; }); html += `
`; html += `
`; // Timeline header html += `
`; html += `
`; timeline.forEach(col => { html += `
${col.label}
`; }); html += `
`; // Tasks html += `
`; const todayPos = calculatePosition(ganttToday, minDate, timeline, colWidth); if (todayPos !== null) { html += `
`; } tasks.forEach(task => { const startPos = calculatePosition(new Date(task.start_datum), minDate, timeline, colWidth); const endPos = calculatePosition(new Date(task.end_datum), minDate, timeline, colWidth) + colWidth; const width = Math.max(endPos - startPos, 3); const gewerk = task.gewerk || 'Sonstige'; const color = gewerkFarben[gewerk] || '#6B7280'; const progress = Math.min(100, Math.max(0, task.progress || 0)); html += `
`; html += `
`; if (progress > 0) { html += `
`; } html += `
`; }); html += `
`; container.innerHTML = html; } function generateTimeline(startDate, endDate, zoomLevel) { const timeline = []; let current = new Date(startDate); current.setHours(0, 0, 0, 0); if (zoomLevel === 'tage') { while (current <= endDate) { timeline.push({ date: new Date(current), label: current.getDate() }); current.setDate(current.getDate() + 1); } } else if (zoomLevel === 'wochen') { const startWeek = Math.floor(current.getDate() / 7); while (current <= endDate) { const week = Math.ceil(current.getDate() / 7); if (!timeline.length || timeline[timeline.length - 1].week !== week) { timeline.push({ date: new Date(current), label: `KW${String(Math.ceil(current.getDate() / 7)).padStart(2, '0')}`, week: week }); } current.setDate(current.getDate() + 7); } } else { while (current <= endDate) { timeline.push({ date: new Date(current), label: current.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }) }); current.setMonth(current.getMonth() + 1); } } return timeline; } function calculatePosition(date, minDate, timeline, colWidth) { const daysDiff = Math.floor((date.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)); if (ganttZoomLevel === 'tage') { return daysDiff * colWidth; } else if (ganttZoomLevel === 'wochen') { const weeksDiff = Math.floor(daysDiff / 7); return weeksDiff * colWidth; } else { const monthsDiff = Math.floor(daysDiff / 30); return monthsDiff * colWidth; } } function ganttScrollToToday() { const container = document.getElementById('ganttContainer'); const ganttToday = new Date(); container.scrollLeft = Math.max(0, 220 + calculatePosition(ganttToday, new Date(ganttToday.getFullYear(), ganttToday.getMonth(), 1), [], 60) - 400); } // ═══ P13: FUHRPARK & GERÄTE ═══ async function loadFuhrpark() { try { const [fahrzeuge, maschinen, alerts] = await Promise.all([ apiCall('/fahrzeuge'), apiCall('/maschinen'), apiCall('/wartungen/alerts') ]); document.getElementById('fleetVehicles').textContent = (fahrzeuge||[]).length; document.getElementById('fleetMachines').textContent = (maschinen||[]).length; document.getElementById('fleetAlerts').textContent = (alerts||[]).length; document.getElementById('fleetVehiclesMasch').textContent = (fahrzeuge||[]).length; document.getElementById('fleetMachinesMasch').textContent = (maschinen||[]).length; document.getElementById('fleetAlertsMasch').textContent = (alerts||[]).length; document.getElementById('fleetVehiclesWart').textContent = (fahrzeuge||[]).length; document.getElementById('fleetMachinesWart').textContent = (maschinen||[]).length; document.getElementById('fleetAlertsWart').textContent = (alerts||[]).length; renderFleetContent(fahrzeuge||[], 'fahrzeuge'); loadFuhrparkUebersicht(); } catch(e) { console.error('Fuhrpark:', e); } } function renderFleetContent(items, typ) { const el = typ === 'fahrzeuge' ? document.getElementById('fleetContent') : typ === 'maschinen' ? document.getElementById('fleetContentMasch') : document.getElementById('fleetContentWart'); if (!items.length) { el.innerHTML = '

Keine Einträge vorhanden

'; return; } let html = '
'; items.forEach(item => { const badge = item.status === 'aktiv' ? 'background:#22B8CF' : item.status === 'wartung' ? 'background:#f59e0b' : 'background:#999'; html += `
${item.name} ${item.status}
${item.typ||''} ${item.kennzeichen||item.hersteller||''}
`; }); html += '
'; el.innerHTML = html; } function switchFuhrparkTab(tab) { document.querySelectorAll('[id^="fuhrparkContent"]').forEach(el => el.style.display = 'none'); document.querySelectorAll('[id^="fuhrparkTab"]').forEach(btn => { btn.style.color = 'var(--text-secondary)'; btn.style.borderBottomColor = 'transparent'; }); if (tab === 'fahrzeuge') { document.getElementById('fuhrparkContentFahrzeuge').style.display = 'block'; document.getElementById('fuhrparkTabFahrzeuge').style.color = 'var(--color-cyan)'; document.getElementById('fuhrparkTabFahrzeuge').style.borderBottomColor = 'var(--color-cyan)'; apiCall('/fahrzeuge').then(d => renderFleetContent(d||[],'fahrzeuge')).catch(() => {}); } else if (tab === 'maschinen') { document.getElementById('fuhrparkContentMaschinen').style.display = 'block'; document.getElementById('fuhrparkTabMaschinen').style.color = 'var(--color-cyan)'; document.getElementById('fuhrparkTabMaschinen').style.borderBottomColor = 'var(--color-cyan)'; apiCall('/maschinen').then(d => renderFleetContent(d||[],'maschinen')).catch(() => {}); } else if (tab === 'wartungen') { document.getElementById('fuhrparkContentWartungen').style.display = 'block'; document.getElementById('fuhrparkTabWartungen').style.color = 'var(--color-cyan)'; document.getElementById('fuhrparkTabWartungen').style.borderBottomColor = 'var(--color-cyan)'; apiCall('/wartungen/alerts').then(d => renderFleetContent(d||[],'wartungen')).catch(() => {}); } else if (tab === 'gps') { document.getElementById('fuhrparkContentGPS').style.display = 'block'; document.getElementById('fuhrparkTabGPS').style.color = 'var(--color-cyan)'; document.getElementById('fuhrparkTabGPS').style.borderBottomColor = 'var(--color-cyan)'; loadFuhrparkGPSTracking(); } else if (tab === 'uebersicht') { document.getElementById('fuhrparkContentUebersicht').style.display = 'block'; document.getElementById('fuhrparkTabUebersicht').style.color = 'var(--color-cyan)'; document.getElementById('fuhrparkTabUebersicht').style.borderBottomColor = 'var(--color-cyan)'; loadFuhrparkUebersicht(); } } async function loadFuhrparkUebersicht() { try { const [fahrzeuge, maschinen, alerts] = await Promise.all([ apiCall('/fahrzeuge'), apiCall('/maschinen'), apiCall('/wartungen/alerts') ]); const fahrzeugeData = fahrzeuge||[]; const maschinenData = maschinen||[]; const alertsData = alerts||[]; document.getElementById('kpiFahrzeugeGesamt').textContent = fahrzeugeData.length; document.getElementById('kpiMaschinenGesamt').textContent = maschinenData.length; document.getElementById('kpiTuvFaellig').textContent = (fahrzeugeData.filter(f => f.tuv_status === 'warning' || f.tuv_status === 'error').length); document.getElementById('kpiWartungenOffen').textContent = alertsData.length; } catch(e) { console.error('Fuhrpark Übersicht:', e); } } function switchFleetTab(tab) { switchFuhrparkTab(tab); } // ═══ P14: BAUSTELLENFOTOS ═══ async function loadBaustellenfotos() { try { const fotos = await apiCall('/baustellenfotos'); const gallery = document.getElementById('fotosGallery'); if (!fotos || !fotos.length) { gallery.innerHTML = '

Keine Fotos vorhanden

'; return; } gallery.innerHTML = fotos.map(f => `
${escapeHtml(f.beschreibung||f.dateiname||'Foto')}
`).join(''); } catch(e) { console.error('Fotos:', e); } } // ═══ P14: BESTELLWESEN ═══ async function loadBestellwesen() { try { const best = await apiCall('/bestellungen'); const el = document.getElementById('bestellContent'); if (!best || !best.length) { el.innerHTML = '

Keine Bestellungen vorhanden

'; return; } let html = ''; best.forEach(b => { html += ``; }); html += '
NrLieferantBetragStatus
${b.nummer||'-'}${b.lieferant_name||'-'}${formatCurrency(b.betrag||0)}${b.status||'offen'}
'; el.innerHTML = html; } catch(e) { console.error('Bestellwesen:', e); } } function switchBestellwesenTab(tab) { document.querySelectorAll('[id^="bestellwesenContent"]').forEach(el => el.style.display = 'none'); document.querySelectorAll('[id^="bestellwesenTab"]').forEach(btn => { btn.style.color = 'var(--text-secondary)'; btn.style.borderBottomColor = 'transparent'; }); if (tab === 'angebote') { document.getElementById('bestellwesenContentAngebote').style.display = 'block'; document.getElementById('bestellwesenTabAngebote').style.color = 'var(--color-cyan)'; document.getElementById('bestellwesenTabAngebote').style.borderBottomColor = 'var(--color-cyan)'; const bestellungId = document.activeElement?.dataset?.bestellungId || prompt('Bestellung ID:'); if (bestellungId) loadBestellwesenSupplierComparison(bestellungId); } else if (tab === 'bestellungen') { document.getElementById('bestellwesenContentBestellungen').style.display = 'block'; document.getElementById('bestellwesenTabBestellungen').style.color = 'var(--color-cyan)'; document.getElementById('bestellwesenTabBestellungen').style.borderBottomColor = 'var(--color-cyan)'; apiCall('/bestellungen').then(d => { const list = document.getElementById('bestellwesenContentBestellungen'); if (!d?.length) { list.innerHTML = '

Keine Bestellungen vorhanden

'; return; } let html = ``; d.forEach(b => { html += ``; }); html += '
Nr.LieferantSummeStatus
${b.nummer}${b.lieferant_name}${(b.gesamt||0).toFixed(2)} €${b.status}
'; list.innerHTML = html; }).catch(() => {}); } } // GPS Tracking & Supplier Comparison Components // ============================================================================ // ============================================================================ // GPS TRACKING COMPONENTS (Fuhrpark) // ============================================================================ async function loadFuhrparkGPSTracking() { try { const fahrzeuge = await apiCall('/fahrzeuge/tracking/live'); renderGPSTrackingUI(fahrzeuge || []); } catch(e) { console.error('GPS Tracking load error:', e); document.getElementById('fuhrparkContentGPS').innerHTML = '
Fehler beim Laden der GPS-Daten
'; } } function renderGPSTrackingUI(fahrzeuge) { const container = document.getElementById('fuhrparkContentGPS'); // Create map and vehicle list side-by-side let html = `
0 Fahrzeuge aktiv

Fuhrpark Status

`; // Load Leaflet dynamically if not already loaded if (!document.querySelector('link[href*="leaflet"]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css'; document.head.appendChild(link); } if (!window.L && !document.querySelector('script[src*="leaflet"]')) { const s = document.createElement('script'); s.src = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js'; document.head.appendChild(s); } container.innerHTML = html; // Initialize map after DOM is ready setTimeout(() => { initGPSMap(fahrzeuge); renderVehiclesList(fahrzeuge); }, 100); } function initGPSMap(fahrzeuge) { // Default center Berlin let mapCenter = [52.52, 13.405]; let hasGPSData = false; // Find a vehicle with GPS data for center for (let v of fahrzeuge) { if (v.latitude && v.longitude) { mapCenter = [v.latitude, v.longitude]; hasGPSData = true; break; } } // Create map const map = L.map('gpsMap').setView(mapCenter, 10); // Add tile layer L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); // Add markers for each vehicle with GPS data let markerGroup = L.featureGroup().addTo(map); let markersAdded = 0; fahrzeuge.forEach(v => { if (v.latitude && v.longitude) { const marker = L.circleMarker([v.latitude, v.longitude], { radius: 8, fillColor: v.status === 'aktiv' ? '#22B8CF' : '#999', color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.8 }).bindPopup(` ${v.name}
${v.kennzeichen}
Geschwindigkeit: ${v.geschwindigkeit || 0} km/h
${v.zeitstempel ? new Date(v.zeitstempel).toLocaleString('de-DE') : 'Keine Position'} `); markerGroup.addLayer(marker); markersAdded++; } }); // Fit bounds if markers exist if (markersAdded > 0) { try { map.fitBounds(markerGroup.getBounds(), { padding: [50, 50] }); } catch(e) { // Single marker, just set view } } document.getElementById('gpsVehicleCount').textContent = markersAdded; // Store map for later use window.gpsMap = map; } function renderVehiclesList(fahrzeuge) { const list = document.getElementById('vehiclesList'); let html = ''; fahrzeuge.forEach(v => { const hasLocation = v.latitude && v.longitude; const status = v.status === 'aktiv' ? 'Aktiv' : v.status === 'wartung' ? 'Wartung' : 'Inaktiv'; const statusColor = v.status === 'aktiv' ? '#22B8CF' : v.status === 'wartung' ? '#f59e0b' : '#999'; html += `
${v.name} ${status}
${v.kennzeichen || 'Kein Kennzeichen'} • ${v.typ || ''}
${hasLocation ? `
Pos: ${v.latitude?.toFixed(4)}, ${v.longitude?.toFixed(4)}
Geschw: ${v.geschwindigkeit || 0} km/h
${v.zeitstempel ? `
Aktualisiert: ${new Date(v.zeitstempel).toLocaleTimeString('de-DE')}
` : ''}
` : `
Keine GPS-Position verfügbar
`}
`; }); if (!fahrzeuge.length) { html = '

Keine Fahrzeuge vorhanden

'; } list.innerHTML = html; } async function showVehicleTrackingHistory(fahrzeugId) { try { const history = await apiCall(`/fahrzeuge/${fahrzeugId}/tracking/history?limit=200`); const fahrzeug = await apiCall(`/fahrzeuge/${fahrzeugId}`); showModalDialog('Fahrzeug Tracking: ' + fahrzeug.name, `
${(history || []).map(h => ` `).join('')}
Zeit Geschwindigkeit
${new Date(h.zeitstempel).toLocaleTimeString('de-DE')} ${h.geschwindigkeit || 0} km/h
`); // Initialize history map setTimeout(() => { initTrackingHistoryMap(history || []); }, 100); } catch(e) { console.error('Tracking history error:', e); } } function initTrackingHistoryMap(history) { if (!history.length) return; const mapCenter = [history[0].latitude, history[0].longitude]; const map = L.map('historyMapContainer').setView(mapCenter, 12); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map); // Draw polyline const coords = history.map(h => [h.latitude, h.longitude]); L.polyline(coords, { color: '#22B8CF', weight: 3, opacity: 0.7 }).addTo(map); // Add start and end markers L.circleMarker(coords[0], { radius: 6, fillColor: '#4ade80', color: '#fff', weight: 2, fillOpacity: 0.8 }).bindPopup('Start').addTo(map); L.circleMarker(coords[coords.length - 1], { radius: 6, fillColor: '#ef4444', color: '#fff', weight: 2, fillOpacity: 0.8 }).bindPopup('Ende').addTo(map); map.fitBounds(L.polyline(coords).getBounds(), { padding: [50, 50] }); } // ============================================================================ // SUPPLIER COMPARISON COMPONENTS (Bestellwesen) // ============================================================================ async function loadBestellwesenSupplierComparison(bestellungId) { try { const angebote = await apiCall(`/lieferanten-angebote?bestellung_id=${bestellungId}`); renderSupplierComparisonUI(angebote || [], bestellungId); } catch(e) { console.error('Supplier comparison error:', e); } } function renderSupplierComparisonUI(angebote, bestellungId) { const container = document.getElementById('bestellwesenContentAngebote'); // Group angebote by artikel const byArtikel = {}; angebote.forEach(a => { if (!byArtikel[a.artikel]) byArtikel[a.artikel] = []; byArtikel[a.artikel].push(a); }); let html = `
`; Object.entries(byArtikel).forEach(([artikel, offers]) => { // Sort by price offers.sort((a, b) => a.einzelpreis - b.einzelpreis); const minPrice = offers[0].einzelpreis; const maxPrice = offers[offers.length - 1].einzelpreis; html += `

${artikel}

${offers.map((o, idx) => { const isBest = idx === 0; const savings = maxPrice - o.einzelpreis; const savingsPercent = ((savings / maxPrice) * 100).toFixed(1); const bgColor = isBest ? 'rgba(34, 184, 207, 0.1)' : 'transparent'; const borderLeft = isBest ? '3px solid var(--color-cyan)' : 'none'; return ` `; }).join('')}
Lieferant Einzelpreis Lieferzeit Einsparung Aktion
${o.lieferant_name || 'Unbekannt'}
${o.lieferant_email || ''}
${o.einzelpreis.toFixed(2)} € ${isBest ? '
BEST' : ''}
${o.lieferzeit_tage} Tage ${isBest ? '-' : ` -${savingsPercent}%
${savings.toFixed(2)} € `}
`; }); if (!Object.keys(byArtikel).length) { html = '

Keine Angebote vorhanden

'; } document.getElementById('bestellwesenContentAngebote').innerHTML = html; } function showAddSupplierOfferModal(bestellungId) { showModalDialog('Neues Lieferantenangebot', `
`); // Load suppliers apiCall('/lieferanten').then(suppliers => { const select = document.getElementById('offerLieferant'); (suppliers || []).forEach(s => { const option = document.createElement('option'); option.value = s.id; option.textContent = s.name; select.appendChild(option); }); }).catch(() => {}); // Handle form submission document.getElementById('supplierOfferForm').addEventListener('submit', async (e) => { e.preventDefault(); const data = { bestellung_id: bestellungId, lieferant_id: document.getElementById('offerLieferant').value, artikel: document.getElementById('offerArtikel').value, menge: parseFloat(document.getElementById('offerMenge').value), einheit: document.getElementById('offerEinheit').value, einzelpreis: parseFloat(document.getElementById('offerEinzelpreis').value), lieferzeit_tage: parseInt(document.getElementById('offerLieferzeit').value) || 0, gueltig_bis: document.getElementById('offerGueltigBis').value || null, notizen: document.getElementById('offerNotizen').value }; try { await apiCall('/lieferanten-angebote', 'POST', data); closeModalDialog(); loadBestellwesenSupplierComparison(bestellungId); } catch(e) { alert('Fehler beim Speichern: ' + e.message); } }); } async function selectSupplierOffer(angebotId) { // This would integrate with the Bestellungen workflow alert('Angebot ' + angebotId + ' ausgewählt (Integration mit Bestellungen erforderlich)'); } // ============ TEAM-CHAT FUNCTIONS ============ let chatCurrentKanal = null; let chatPollingInterval = null; async function loadChat() { try { const kanaele = await apiCall('/chat/kanaele'); const list = document.getElementById('chatKanalList'); if (!list) return; if (!kanaele || kanaele.length === 0) { list.innerHTML = `
Noch keine Kanäle vorhanden.
Erstelle einen neuen Kanal.
`; document.getElementById('chatMessages').innerHTML = ''; document.getElementById('chatHeader').textContent = 'Kanal wählen...'; return; } list.innerHTML = kanaele.map(k => `
# ${k.name || 'Unbenannt'} ${k.unread > 0 ? `${k.unread}` : ''}
`).join(''); // Auto-select first channel if none selected if (!chatCurrentKanal && kanaele.length > 0) { selectChatKanal(kanaele[0].id, kanaele[0].name || 'Unbenannt'); } } catch(e) { console.error('Chat laden fehlgeschlagen:', e); showToast('Chat konnte nicht geladen werden', 'error'); } } async function selectChatKanal(kanalId, kanalName) { chatCurrentKanal = kanalId; // Update header const header = document.getElementById('chatHeader'); if (header) header.textContent = `# ${kanalName}`; // Highlight active channel document.querySelectorAll('.chat-kanal-item').forEach(el => { el.style.background = ''; el.style.color = ''; }); const active = document.getElementById(`chatKanal-${kanalId}`); if (active) { active.style.background = 'var(--color-cyan)'; active.style.color = 'white'; } await loadChatNachrichten(kanalId); // Start polling for new messages if (chatPollingInterval) clearInterval(chatPollingInterval); chatPollingInterval = setInterval(() => { if (currentPage === 'chat' && chatCurrentKanal === kanalId) { loadChatNachrichten(kanalId); } else { clearInterval(chatPollingInterval); } }, 10000); } async function loadChatNachrichten(kanalId) { try { const nachrichten = await apiCall(`/chat/kanaele/${kanalId}`); const container = document.getElementById('chatMessages'); if (!container) return; // The kanaele/{id} endpoint returns channel details; messages may be separate // Try dedicated nachrichten endpoint first let messages = []; try { messages = await apiCall(`/chat/nachrichten?kanal_id=${kanalId}`); } catch(e2) { // Fallback: messages might be empty messages = []; } if (!messages || messages.length === 0) { container.innerHTML = `
Noch keine Nachrichten in diesem Kanal.
Schreibe die erste Nachricht!
`; return; } const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); container.innerHTML = messages.map(msg => { const isOwn = msg.sender_id === currentUser.id; const zeit = msg.created_at ? new Date(msg.created_at).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}) : ''; return `
${msg.sender_name || 'Unbekannt'} · ${zeit}
${msg.nachricht || ''} ${msg.anhang_url ? `
📎 Anhang
` : ''}
`; }).join(''); // Scroll to bottom container.scrollTop = container.scrollHeight; } catch(e) { console.error('Nachrichten laden fehlgeschlagen:', e); } } async function sendChatNachricht() { const input = document.getElementById('chatInput'); if (!input || !input.value.trim() || !chatCurrentKanal) return; const nachricht = input.value.trim(); input.value = ''; try { await apiCall(`/chat/nachrichten`, 'POST', { kanal_id: chatCurrentKanal, nachricht: nachricht, typ: 'text' }); await loadChatNachrichten(chatCurrentKanal); } catch(e) { showToast('Nachricht konnte nicht gesendet werden', 'error'); input.value = nachricht; // Restore on failure } } function showNewKanalModal() { const modal = document.createElement('div'); modal.id = 'chatKanalModal'; modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:10000;'; modal.innerHTML = `

Neuen Kanal erstellen

`; document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); document.getElementById('newKanalForm').addEventListener('submit', async (e) => { e.preventDefault(); try { await apiCall('/chat/kanaele', 'POST', { name: document.getElementById('newKanalName').value, typ: document.getElementById('newKanalTyp').value, beschreibung: document.getElementById('newKanalBeschreibung').value }); modal.remove(); showToast('Kanal erstellt!', 'success'); await loadChat(); } catch(err) { showToast('Kanal konnte nicht erstellt werden', 'error'); } }); } // ============ WEBHOOK MANAGEMENT FUNCTIONS ============ const WEBHOOK_EVENTS = [ // Projects { id: 'project.created', label: 'Projekt erstellt', category: 'Projekte' }, { id: 'project.updated', label: 'Projekt aktualisiert', category: 'Projekte' }, { id: 'project.deleted', label: 'Projekt gelöscht', category: 'Projekte' }, // Baustellen { id: 'baustelle.created', label: 'Baustelle erstellt', category: 'Baustellen' }, { id: 'baustelle.updated', label: 'Baustelle aktualisiert', category: 'Baustellen' }, { id: 'baustelle.deleted', label: 'Baustelle gelöscht', category: 'Baustellen' }, // Aufgaben { id: 'aufgabe.created', label: 'Aufgabe erstellt', category: 'Aufgaben' }, { id: 'aufgabe.updated', label: 'Aufgabe aktualisiert', category: 'Aufgaben' }, { id: 'aufgabe.status_changed', label: 'Aufgabe Status geändert', category: 'Aufgaben' }, { id: 'aufgabe.deleted', label: 'Aufgabe gelöscht', category: 'Aufgaben' }, // Kunden { id: 'kunde.created', label: 'Kunde erstellt', category: 'Kunden' }, { id: 'kunde.updated', label: 'Kunde aktualisiert', category: 'Kunden' }, { id: 'kunde.deleted', label: 'Kunde gelöscht', category: 'Kunden' }, // Angebote { id: 'angebot.created', label: 'Angebot erstellt', category: 'Angebote' }, { id: 'angebot.updated', label: 'Angebot aktualisiert', category: 'Angebote' }, { id: 'angebot.accepted', label: 'Angebot akzeptiert', category: 'Angebote' }, { id: 'angebot.deleted', label: 'Angebot gelöscht', category: 'Angebote' }, // Rechnungen { id: 'rechnung.created', label: 'Rechnung erstellt', category: 'Rechnungen' }, { id: 'rechnung.updated', label: 'Rechnung aktualisiert', category: 'Rechnungen' }, { id: 'rechnung.paid', label: 'Rechnung bezahlt', category: 'Rechnungen' }, { id: 'rechnung.deleted', label: 'Rechnung gelöscht', category: 'Rechnungen' }, // Mängel { id: 'mangel.created', label: 'Mangel gemeldet', category: 'Mängel' }, { id: 'mangel.updated', label: 'Mangel aktualisiert', category: 'Mängel' }, { id: 'mangel.resolved', label: 'Mangel behoben', category: 'Mängel' }, { id: 'mangel.deleted', label: 'Mangel gelöscht', category: 'Mängel' }, // Termine { id: 'termin.created', label: 'Termin erstellt', category: 'Termine' }, { id: 'termin.updated', label: 'Termin aktualisiert', category: 'Termine' }, { id: 'termin.deleted', label: 'Termin gelöscht', category: 'Termine' }, // Zeiterfassung { id: 'zeiterfassung.created', label: 'Zeiterfassung erstellt', category: 'Zeiterfassung' }, // Nachträge { id: 'nachtrag.created', label: 'Nachtrag erstellt', category: 'Nachträge' }, { id: 'nachtrag.updated', label: 'Nachtrag aktualisiert', category: 'Nachträge' }, { id: 'nachtrag.approved', label: 'Nachtrag genehmigt', category: 'Nachträge' }, { id: 'nachtrag.deleted', label: 'Nachtrag gelöscht', category: 'Nachträge' }, // Dokumente { id: 'dokument.uploaded', label: 'Dokument hochgeladen', category: 'Dokumente' }, { id: 'dokument.deleted', label: 'Dokument gelöscht', category: 'Dokumente' }, // Tests { id: 'test.event', label: 'Test Event', category: 'Tests' } ]; async function loadWebhooksOnPageLoad() { if (document.getElementById('webhooksList')) { await loadWebhooksList(); renderWebhookEventCheckboxes(); } } async function loadWebhooksList() { try { const response = await apiCall('/webhooks', 'GET'); const webhooks = response.webhooks || []; let html = ''; if (webhooks.length === 0) { html = '
Keine Webhooks konfiguriert
'; } else { webhooks.forEach(webhook => { const status = webhook.is_active ? 'active' : 'inactive'; const statusLabel = webhook.is_active ? 'Aktiv' : 'Inaktiv'; const eventCount = webhook.events ? webhook.events.split(',').length : 0; html += `
${escapeHtml(webhook.name)}
${escapeHtml(webhook.url)}
${eventCount} Ereignis${eventCount !== 1 ? 'se' : ''} | Erstellt: ${new Date(webhook.created_at).toLocaleDateString('de-DE')}
${statusLabel}
`; }); } document.getElementById('webhooksList').innerHTML = html; } catch (error) { console.error('Error loading webhooks:', error); document.getElementById('webhooksList').innerHTML = `
Fehler beim Laden: ${error.message}
`; } } function renderWebhookEventCheckboxes() { const container = document.getElementById('webhookEventsContainer'); if (!container) return; let html = ''; const categories = {}; // Group events by category WEBHOOK_EVENTS.forEach(event => { if (!categories[event.category]) { categories[event.category] = []; } categories[event.category].push(event); }); // Render grouped checkboxes Object.keys(categories).sort().forEach(category => { html += `
${category}
`; categories[category].forEach(event => { html += `
`; }); }); container.innerHTML = html; } function showWebhookModal() { document.getElementById('webhookModalId').value = ''; document.getElementById('webhookModalTitle').textContent = 'Neuer Webhook'; document.getElementById('webhookModalName').value = ''; document.getElementById('webhookModalUrl').value = ''; document.getElementById('webhookModalActive').checked = true; document.getElementById('webhookModalSaveBtn').textContent = 'Webhook erstellen'; // Clear all checkboxes document.querySelectorAll('.webhook-event-checkbox').forEach(cb => cb.checked = false); openModal('webhookModal'); } async function editWebhook(webhookId) { try { const response = await apiCall(`/webhooks/${webhookId}`, 'GET'); const webhook = response.webhook; document.getElementById('webhookModalId').value = webhook.id; document.getElementById('webhookModalTitle').textContent = 'Webhook bearbeiten'; document.getElementById('webhookModalName').value = webhook.name; document.getElementById('webhookModalUrl').value = webhook.url; document.getElementById('webhookModalActive').checked = webhook.is_active; document.getElementById('webhookModalSaveBtn').textContent = 'Speichern'; // Clear and set checkboxes based on events const eventTypes = webhook.events ? webhook.events.split(',') : []; document.querySelectorAll('.webhook-event-checkbox').forEach(cb => { cb.checked = eventTypes.includes(cb.value); }); openModal('webhookModal'); } catch (error) { alert('Fehler beim Laden des Webhooks: ' + error.message); } } async function saveWebhookModal() { const id = document.getElementById('webhookModalId').value; const name = document.getElementById('webhookModalName').value.trim(); const url = document.getElementById('webhookModalUrl').value.trim(); const isActive = document.getElementById('webhookModalActive').checked; if (!name || !url) { alert('Bitte füllen Sie alle erforderlichen Felder aus'); return; } // Get selected events const selectedEvents = Array.from(document.querySelectorAll('.webhook-event-checkbox:checked')) .map(cb => cb.value); if (selectedEvents.length === 0) { alert('Bitte wählen Sie mindestens ein Ereignis aus'); return; } try { const data = { url: url, events: selectedEvents.join(','), is_active: isActive ? 1 : 0 }; if (id) { await apiCall(`/webhooks/${id}`, 'PATCH', data); } else { await apiCall('/webhooks', 'POST', data); } closeModal('webhookModal'); await loadWebhooksList(); } catch (error) { alert('Fehler beim Speichern: ' + error.message); } } async function deleteWebhook(webhookId) { if (!confirm('Sind Sie sicher, dass Sie diesen Webhook löschen möchten?')) { return; } try { await apiCall(`/webhooks/${webhookId}`, 'DELETE'); await loadWebhooksList(); } catch (error) { alert('Fehler beim Löschen: ' + error.message); } } async function testWebhook(webhookId) { openModal('webhookTestModal'); const resultDiv = document.getElementById('webhookTestResult'); const loadingDiv = document.getElementById('webhookTestLoading'); resultDiv.style.display = 'none'; loadingDiv.style.display = 'block'; try { const response = await apiCall(`/webhooks/${webhookId}/test`, 'POST'); loadingDiv.style.display = 'none'; resultDiv.style.display = 'block'; resultDiv.textContent = JSON.stringify(response, null, 2); } catch (error) { loadingDiv.style.display = 'none'; resultDiv.style.display = 'block'; resultDiv.style.color = 'var(--color-red)'; resultDiv.textContent = 'Test fehlgeschlagen:\n\n' + error.message; } } let currentWebhookForLogs = null; async function viewWebhookLogs(webhookId) { currentWebhookForLogs = webhookId; openModal('webhookLogsModal'); await loadWebhookLogs(); } async function loadWebhookLogs() { if (!currentWebhookForLogs) return; try { const filter = document.getElementById('webhookLogsFilter').value; let url = `/webhooks/${currentWebhookForLogs}/logs`; if (filter) { url += `?status=${filter}`; } const response = await apiCall(url, 'GET'); const logs = response.logs || []; let html = ''; if (logs.length === 0) { html = '
Keine Lieferungen
'; } else { logs.forEach(log => { const statusClass = log.success == 1 ? 'success' : 'failed'; const statusText = log.success == 1 ? 'Erfolgreich' : 'Fehlgeschlagen'; const timestamp = new Date(log.created_at).toLocaleString('de-DE'); html += `
${timestamp}
${escapeHtml(log.event_type)}
${statusText} ${log.response_status ? ` HTTP ${log.response_status}` : ''} ${log.retry_count > 0 ? ` (Versuch ${log.retry_count + 1})` : ''}
${log.error_message ? `
${escapeHtml(log.error_message)}
` : ''}
`; }); } document.getElementById('webhookLogsList').innerHTML = html; } catch (error) { document.getElementById('webhookLogsList').innerHTML = `
Fehler: ${error.message}
`; } } // Load webhooks when settings page is accessed document.addEventListener('DOMContentLoaded', function() { // Hook into page navigation const originalGoToPage = window.goToPage; window.goToPage = function(page) { if (originalGoToPage) originalGoToPage.call(this, page); if (page === 'settings') { setTimeout(loadWebhooksOnPageLoad, 100); } }; }); // ===== MODULE 1: PROJEKTCONTROLLING & NACHKALKULATION ===== let controllingChartInstance = null; let cashflowChartInstance = null; function switchControllingTab(tab) { document.getElementById('controllingContentUebersicht').style.display = tab === 'uebersicht' ? '' : 'none'; document.getElementById('controllingContentNachkalkulation').style.display = tab === 'nachkalkulation' ? '' : 'none'; document.getElementById('controllingContentKostenstellen').style.display = tab === 'kostenstellen' ? '' : 'none'; document.getElementById('controllingContentCashflow').style.display = tab === 'cashflow' ? '' : 'none'; document.querySelectorAll('.tab-btn').forEach(btn => btn.style.borderBottomColor = 'transparent'); const tabBtn = document.getElementById('controllingTab' + tab.charAt(0).toUpperCase() + tab.slice(1)); if (tabBtn) tabBtn.style.borderBottomColor = 'var(--color-cyan)'; if (tab === 'uebersicht') loadControllingUebersicht(); else if (tab === 'nachkalkulation') loadNachkalkulation(); else if (tab === 'kostenstellen') loadKostenstellen(); else if (tab === 'cashflow') loadCashflowForecast(); } async function loadControlling() { try { await loadControllingUebersicht(); } catch(e) { showToast('Controlling konnte nicht geladen werden','error'); } } async function loadControllingUebersicht() { try { const overview = await apiCall('/api/v1/controlling/uebersicht'); document.getElementById('controllingProjectCount').textContent = overview.projectCount || 0; document.getElementById('controllingSollVolume').textContent = (overview.sollVolume || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; document.getElementById('controllingIstVolume').textContent = (overview.istVolume || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; const deviation = (overview.istVolume || 0) - (overview.sollVolume || 0); document.getElementById('controllingDeviation').textContent = deviation.toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; const projects = overview.projects || []; const projectsHtml = projects.map(p => ` ${escapeHtml(p.name)} ${(p.sollVolume || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(p.istVolume || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${((p.istVolume || 0) - (p.sollVolume || 0)).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(p.deviation || 0).toFixed(1)}% ${p.status} `).join(''); document.getElementById('controllingProjectList').innerHTML = projectsHtml || 'Keine Projekte'; if (controllingChartInstance) controllingChartInstance.destroy(); const ctx = document.getElementById('controllingChart')?.getContext('2d'); if (ctx && projects.length > 0) { controllingChartInstance = new Chart(ctx, { type: 'bar', data: { labels: projects.map(p => p.name.substring(0, 20)), datasets: [ { label: 'SOLL', data: projects.map(p => p.sollVolume || 0), backgroundColor: 'var(--color-cyan)' }, { label: 'IST', data: projects.map(p => p.istVolume || 0), backgroundColor: 'var(--color-warning)' } ] }, options: { responsive: true, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } } }); } } catch(e) { console.error('Controlling Übersicht Fehler:', e); } } async function loadNachkalkulation() { try { const nk = await apiCall('/api/v1/projekte/0/nachkalkulation'); document.getElementById('nachkalkulationCount').textContent = nk.count || 0; document.getElementById('nachkalkulationAvgDev').textContent = (nk.avgDeviation || 0).toFixed(1) + '%'; document.getElementById('nachkalkulationPlanned').textContent = (nk.plannedCosts || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; document.getElementById('nachkalkulationActual').textContent = (nk.actualCosts || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; const table = (nk.byGewerk || []).map(g => ` ${escapeHtml(g.gewerk)} ${(g.planned || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(g.actual || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${((g.actual || 0) - (g.planned || 0)).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(g.deviation || 0).toFixed(1)}% `).join(''); document.getElementById('nachkalkulationTable').innerHTML = table || 'Keine Daten'; } catch(e) { console.error('Nachkalkulation Fehler:', e); } } async function loadKostenstellen() { try { const ks = await apiCall('/api/v1/kostenstellen'); const ksHtml = (ks || []).map(k => ` ${k.nummer} ${escapeHtml(k.name)} ${(k.budget || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(k.spent || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(k.available || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${((k.spent || 0) / (k.budget || 1) * 100).toFixed(1)}% `).join(''); document.getElementById('kostenstellenList').innerHTML = ksHtml || 'Keine Kostenstellen'; const ka = await apiCall('/api/v1/kostenarten'); const kaHtml = (ka || []).map(k => ` ${k.nummer} ${escapeHtml(k.name)} `).join(''); document.getElementById('kostenartList').innerHTML = kaHtml || 'Keine Kostenarten'; } catch(e) { console.error('Kostenstellen Fehler:', e); } } async function loadCashflowForecast() { try { const cf = await apiCall('/api/v1/cashflow/liquiditaet'); document.getElementById('cashflowLiquiditaet').textContent = (cf.liquiditaet || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; document.getElementById('cashflowEinnahmen').textContent = (cf.einnahmen30d || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; document.getElementById('cashflowAusgaben').textContent = (cf.ausgaben30d || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; document.getElementById('cashflowSaldo').textContent = ((cf.einnahmen30d || 0) - (cf.ausgaben30d || 0)).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; if (cashflowChartInstance) cashflowChartInstance.destroy(); const ctx = document.getElementById('cashflowChart')?.getContext('2d'); if (ctx && cf.forecast) { cashflowChartInstance = new Chart(ctx, { type: 'line', data: { labels: cf.forecast.map((_, i) => 'Tag ' + (i+1)), datasets: [{ label: 'Liquidität', data: cf.forecast, borderColor: 'var(--color-cyan)', backgroundColor: 'rgba(34, 184, 207, 0.1)', tension: 0.4, fill: true }] }, options: { responsive: true, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } } }); } } catch(e) { console.error('Cashflow Fehler:', e); } } function showProjectDetail(projectId) { showToast('Projektdetails folgen bald', 'info'); } async function showAddKostenstelleModal() { openModal('Kostenstelle hinzufügen', '', async () => { await apiCall('/api/v1/kostenstellen', 'POST', {name: document.getElementById('ksName').value, budget: parseFloat(document.getElementById('ksBudget').value || 0)}); closeModal(); showToast('Kostenstelle erstellt','success'); loadKostenstellen(); }); } async function showAddKostenartModal() { openModal('Kostenart hinzufügen', '', async () => { await apiCall('/api/v1/kostenarten', 'POST', {name: document.getElementById('kaName').value}); closeModal(); showToast('Kostenart erstellt','success'); loadKostenstellen(); }); } async function showAddKostenbuchungModal() { openModal('Kostenbuchung hinzufügen', '', async () => { await apiCall('/api/v1/kostenbuchungen', 'POST', {kostenstelle_id: document.getElementById('kbKostenstelle').value, betrag: parseFloat(document.getElementById('kbBetrag').value || 0), beschreibung: document.getElementById('kbBeschreibung').value}); closeModal(); showToast('Kostenbuchung erstellt','success'); }); } // ===== MODULE 2: LV-KALKULATION ERWEITERUNG ===== let currentLVDetaillkalkulation = null; // Enhanced LV Detail - add Detailkalkulation tab async function loadLVDetailWithEnhancement(lvId) { try { const lv = await apiCall(`/lv/${lvId}`); currentLV = lv; document.getElementById('lvDetailTitle').textContent = lv.name; document.getElementById('lvDetailPosCount').textContent = (lv.positionen || []).length; document.getElementById('lvDetailNetto').textContent = (lv.gesamtsumme_netto || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; document.getElementById('lvDetailBrutto').textContent = (lv.gesamtsumme_brutto || 0).toLocaleString('de-DE', {minimumFractionDigits:2}) + ' €'; const posHtml = (lv.positionen || []).map((p, i) => ` ${i+1} ${escapeHtml(p.titel || 'Position ' + (i+1))} ${escapeHtml(p.einheit || '')} ${(p.menge || 0)} ${(p.einheitspreis || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(p.gesamtpreis || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${(p.ki_einheitspreis || 0).toLocaleString('de-DE', {minimumFractionDigits:2})} € ${escapeHtml(p.gewerk || '')} `).join(''); document.getElementById('lvPositionenList').innerHTML = posHtml; document.getElementById('lvListView').style.display = 'none'; document.getElementById('lvDetailView').style.display = ''; } catch(e) { showToast('LV-Detail Fehler','error'); } } async function openDetailkalkulationModal(positionId) { try { const kalk = await apiCall(`/api/v1/lv/position/${positionId}/kalkulation`); currentLVDetaillkalkulation = {positionId, kalk}; openModal('Detailkalkulation - LV-Position', `

Zuschläge (%)

`, async () => { await apiCall(`/api/v1/lv/position/${positionId}/kalkulation`, 'PUT', { material: parseFloat(document.getElementById('dlMaterial').value || 0), lohn: parseFloat(document.getElementById('dlLohn').value || 0), geraete: parseFloat(document.getElementById('dlGeraete').value || 0), fremdleistung: parseFloat(document.getElementById('dlFremd').value || 0), agk: parseFloat(document.getElementById('dlAGK').value || 0), bgk: parseFloat(document.getElementById('dlBGK').value || 0), wagnis: parseFloat(document.getElementById('dlWagnis').value || 0), gewinn: parseFloat(document.getElementById('dlGewinn').value || 0) }); closeModal(); showToast('Kalkulation aktualisiert','success'); loadLVDetailWithEnhancement(currentLV.id); }); } catch(e) { showToast('Detailkalkulation konnte nicht geladen werden','error'); } } ', '' ].join(''); } async function createAndOptionallyExportReport(reportMeta, doExport) { var payload = Object.assign({ status: 'exportiert', createdAt: nowIso() }, reportMeta); var created; try { created = await Api.createReport(payload); created = Object.assign({}, payload, created, { id: created.id || uid('report') }); } catch (error) { created = Object.assign({}, payload, { id: uid('report') }); } state.data.reports.unshift(created); calculateMetrics(); if (doExport) { if (payload.format === 'html' || payload.format === 'pdf-html') { var htmlReport = renderMonitoringReportHtml(buildMonitoringReportPayload(payload.siteId), payload.title); downloadBlob(slugify(payload.title) + '.html', 'text/html;charset=utf-8', htmlReport); } else if (payload.format === 'json') { downloadBlob(slugify(payload.title) + '.json', 'application/json', JSON.stringify(buildMonitoringReportPayload(payload.siteId), null, 2)); } else if (payload.format === 'csv') { var reportRows = [['Kamera', 'Status', 'Signal', 'Personen', 'Fahrzeuge']]; state.data.cameras.filter(function(camera) { return camera.siteId === payload.siteId; }).forEach(function(camera) { reportRows.push([camera.name, camera.status, camera.signalQuality, camera.peopleCount, camera.vehicleCount]); }); downloadBlob(slugify(payload.title) + '.csv', 'text/csv;charset=utf-8', toCsv(reportRows)); } notify('Bericht exportiert.', 'success'); } return created; } function slugify(value) { return String(value || 'export') .toLowerCase() .replace(/[^a-z0-9äöüß]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/ä/g, 'ae') .replace(/ö/g, 'oe') .replace(/ü/g, 'ue') .replace(/ß/g, 'ss'); } function exportSavedReport(reportId) { var report = findById(state.data.reports, reportId); if (!report) { notify('Bericht nicht gefunden.', 'warning'); return; } var payload = buildMonitoringReportPayload(report.siteId); if (report.format === 'json') { downloadBlob(slugify(report.title) + '.json', 'application/json', JSON.stringify(payload, null, 2)); } else if (report.format === 'csv') { var rows = [['Kamera', 'Status', 'Signal', 'Personen', 'Fahrzeuge']]; payload.cameras.forEach(function(camera) { rows.push([camera.name, camera.status, camera.signalQuality, camera.peopleCount, camera.vehicleCount]); }); downloadBlob(slugify(report.title) + '.csv', 'text/csv;charset=utf-8', toCsv(rows)); } else { downloadBlob(slugify(report.title) + '.html', 'text/html;charset=utf-8', renderMonitoringReportHtml(payload, report.title)); } notify('Bericht erneut exportiert.', 'success'); } function exportScreenshotPack() { var snapshots = getSnapshotsForSelectedSite(); if (!snapshots.length) { notify('Keine Snapshots zum Export vorhanden.', 'warning'); return; } var rows = [['ID', 'Label', 'Kamera', 'Erfasst', 'Quelle']]; snapshots.forEach(function(snapshot) { rows.push([ snapshot.id, snapshot.label, getCameraById(snapshot.cameraId) ? getCameraById(snapshot.cameraId).name : snapshot.cameraId, formatDateTime(snapshot.capturedAt), snapshot.source ]); }); downloadBlob('snapshots-' + Date.now() + '.csv', 'text/csv;charset=utf-8', toCsv(rows)); notify('Snapshot-Paket exportiert.', 'success'); } function downloadSnapshot(snapshotId) { var snapshot = findById(state.data.snapshots, snapshotId); if (!snapshot) { notify('Snapshot nicht gefunden.', 'warning'); return; } if (snapshot.imageDataUrl) { downloadDataUrl(slugify(snapshot.label || snapshot.id) + '.png', snapshot.imageDataUrl); notify('Snapshot heruntergeladen.', 'success'); return; } notify('Für diesen Snapshot liegt kein Bild vor. Bitte erneut live erfassen.', 'warning'); } function simulateWeatherEntry(entry) { var next = clone(entry); next.temperature = round(clamp(next.temperature + randomBetween(-0.6, 0.6), -10, 38), 1); next.feelsLike = round(next.temperature - randomBetween(0, 2.4), 1); next.windSpeed = clamp(Math.round(next.windSpeed + randomBetween(-4, 4)), 0, 70); next.windGust = clamp(Math.round(next.windSpeed + randomBetween(4, 14)), next.windSpeed, 90); next.humidity = clamp(Math.round(next.humidity + randomBetween(-6, 6)), 30, 100); next.precipitation = round(clamp(next.precipitation + randomBetween(-0.4, 0.8), 0, 18), 1); next.updatedAt = nowIso(); if (next.windGust >= 45 || next.precipitation >= 4) { next.warningLevel = 'hoch'; next.advisory = 'Erhöhtes Wetterrisiko für Hub- und Außenarbeiten.'; next.condition = next.precipitation > 1 ? 'Schauer' : 'Windig'; next.icon = next.precipitation > 1 ? '🌧️' : '🌬️'; } else if (next.windGust >= 32 || next.precipitation >= 1) { next.warningLevel = 'mittel'; next.advisory = 'Witterung beobachten, Oberflächen können rutschig werden.'; next.condition = next.precipitation > 0.5 ? 'Leichte Schauer' : 'Windig'; next.icon = next.precipitation > 0.5 ? '🌦️' : '🌬️'; } else { next.warningLevel = 'niedrig'; next.advisory = 'Keine relevanten Wetterrisiken.'; next.condition = next.humidity > 70 ? 'Leicht bewölkt' : 'Klar'; next.icon = next.humidity > 70 ? '⛅' : '☀️'; } return next; } function simulateCountTick() { safeArray(state.data.cameras).forEach(function(camera) { if (camera.status === 'offline') { camera.peopleCount = 0; camera.vehicleCount = 0; return; } var scene = ensureScene(camera.id); var desiredPeople = clamp(camera.peopleCount + randomInt(-1, 2), 0, 12); var desiredVehicles = clamp(camera.vehicleCount + randomInt(-1, 1), 0, 6); normalizeSceneObjects(scene, desiredPeople, desiredVehicles); registerCountSample(camera.id, camera.siteId, camera.peopleCount, camera.vehicleCount); }); calculateMetrics(); if (state.ui.activeTab === 'dashboard') { renderDashboardTab(); renderMiniCharts(); } } function normalizeSceneObjects(scene, people, vehicles) { var peopleObjects = scene.objects.filter(function(item) { return item.type === 'person'; }); var vehicleObjects = scene.objects.filter(function(item) { return item.type === 'vehicle'; }); while (peopleObjects.length < people) { var object = { id: uid('scene'), type: 'person', x: randomBetween(0.08, 0.92), y: randomBetween(0.5, 0.88), vx: randomBetween(-0.025, 0.025), vy: randomBetween(-0.01, 0.01), size: randomBetween(0.018, 0.028), heading: randomBetween(0, Math.PI * 2) }; scene.objects.push(object); peopleObjects.push(object); } while (vehicleObjects.length < vehicles) { var car = { id: uid('scene'), type: 'vehicle', x: randomBetween(0.08, 0.92), y: randomBetween(0.54, 0.86), vx: randomBetween(-0.02, 0.02), vy: randomBetween(-0.008, 0.008), size: randomBetween(0.032, 0.055), heading: randomBetween(0, Math.PI * 2) }; scene.objects.push(car); vehicleObjects.push(car); } while (peopleObjects.length > people) { var removedPerson = peopleObjects.pop(); scene.objects = scene.objects.filter(function(item) { return item.id !== removedPerson.id; }); } while (vehicleObjects.length > vehicles) { var removedVehicle = vehicleObjects.pop(); scene.objects = scene.objects.filter(function(item) { return item.id !== removedVehicle.id; }); } } function registerCountSample(cameraId, siteId, people, vehicles) { state.data.countHistory.unshift({ id: uid('count'), siteId: siteId, cameraId: cameraId, timestamp: nowIso(), people: people, vehicles: vehicles }); state.data.countHistory = state.data.countHistory.slice(0, 24 * Math.max(state.data.cameras.length, 1) * 2); } function simulateActivityTick() { var camera = sample(getFilteredCameras({ ignoreSearch: true, ignoreStatus: true })) || sample(state.data.cameras); if (!camera) { return; } var entries = [ { category: 'Lieferung', level: 'info', message: 'Materialtransport im Sichtfeld erkannt.', details: 'Automatisch generiertes Logereignis.' }, { category: 'Sicherheit', level: 'warning', message: 'Kurzzeitige Bewegung im Sicherheitsbereich.', details: 'Zone-Trigger ausgelöst, Prüfung empfohlen.' }, { category: 'Baufortschritt', level: 'info', message: 'Neue Vergleichsreferenz für Kamera verfügbar.', details: 'Snapshot-Paar kann erstellt werden.' }, { category: 'Wetter', level: 'warning', message: 'Wetterlage beeinflusst Sicht und Arbeitsbedingungen.', details: 'Wind-/Niederschlagswert aktualisiert.' }, { category: 'Technik', level: 'info', message: 'Health-Check der Kamera erfolgreich.', details: 'Heartbeat und Signal innerhalb Schwellwert.' } ]; var template = sample(entries); if (!template) { return; } var activity = { id: uid('activity'), siteId: camera.siteId, cameraId: camera.id, category: template.category, level: template.level, timestamp: nowIso(), message: template.message, actor: 'Simulation', details: template.details + ' Kamera: ' + camera.name + '.' }; state.data.activities.unshift(activity); state.data.activities = state.data.activities.slice(0, CONFIG.maxActivities); if (state.ui.activeTab === 'activities') { renderActivitiesTab(); } } function simulateAlertTick() { var activeRules = safeArray(state.data.alertRules).filter(function(rule) { return rule.active && (!state.ui.selectedSiteId || rule.siteId === state.ui.selectedSiteId); }); if (!activeRules.length) { return; } if (Math.random() > 0.28) { return; } var rule = sample(activeRules); var camera = sample(state.data.cameras.filter(function(item) { return item.siteId === rule.siteId; })); if (!camera) { return; } var alert = { id: uid('alert'), siteId: camera.siteId, cameraId: camera.id, ruleId: rule.id, title: rule.name, description: 'Simulierter Regeltrigger für ' + camera.name + '.', severity: rule.severity, status: 'offen', timestamp: nowIso(), acknowledgedBy: null, acknowledgedAt: null, category: rule.type }; state.data.alerts.unshift(alert); state.data.alerts = sortBy(state.data.alerts, function(item) { return new Date(item.timestamp).getTime(); }, 'desc').slice(0, 120); calculateMetrics(); if (state.ui.activeTab === 'security' || state.ui.activeTab === 'dashboard') { rerender(); } notify('Neue Warnung: ' + alert.title, alert.severity === 'kritisch' ? 'error' : 'warning'); } function startBackgroundTasks() { registerInterval(function() { refresh(false); }, CONFIG.refreshIntervalMs); registerInterval(function() { state.data.weather = state.data.weather.map(simulateWeatherEntry); calculateMetrics(); if (state.ui.activeTab === 'dashboard') { renderDashboardTab(); } if (state.ui.activeTab === 'reports') { renderReportsTab(); } }, CONFIG.weatherIntervalMs); registerInterval(function() { simulateActivityTick(); }, CONFIG.activitySimulationIntervalMs); registerInterval(function() { simulateAlertTick(); }, CONFIG.alertSimulationIntervalMs); registerInterval(function() { simulateCountTick(); }, CONFIG.countSimulationIntervalMs); registerInterval(function() { updateClockLabels(); }, CONFIG.clockIntervalMs); } function updateClockLabels() { if (!state.root) { return; } queryAll('[data-camera-canvas]', state.root).forEach(function(canvas) { var cameraId = canvas.getAttribute('data-camera-canvas'); var camera = getCameraById(cameraId); if (camera) { camera.lastSeen = nowIso(); } }); } async function refresh(showNotification) { if (!state.initialized || state.destroyed) { return; } if (state.demoMode) { calculateMetrics(); rerender(); if (showNotification !== false) { notify('Baustellenüberwachung aktualisiert.', 'success'); } return; } await loadAllData(); rerender(); if (showNotification !== false) { notify('Baustellenüberwachung aktualisiert.', 'success'); } } async function init(containerId) { destroy(); state.destroyed = false; state.initialized = false; state.containerId = containerId; state.container = document.getElementById(containerId); if (!state.container) { notify('Container für Baustellenüberwachung nicht gefunden: ' + containerId, 'error'); return; } ensureStyles(); state.demoMode = false; state.loading = false; state.scenes = {}; state.listeners = []; state.intervals = []; state.timeouts = []; state.rafs = []; state.canvasLoops = {}; state.ui = Object.assign({}, state.ui, { activeTab: 'dashboard', viewMode: CONFIG.defaultViewMode, selectedSiteId: 'site-001', selectedCameraId: null, selectedTimelapseId: null, selectedComparisonId: null, selectedWeatherSiteId: null, selectedAlertRuleId: null, selectedZoneCameraId: null, selectedSnapshotCameraId: null, selectedActivityId: null, selectedReportId: null, compareBeforeSnapshotId: null, compareAfterSnapshotId: null, timelapsePlaying: false, timelapseFrameIndex: 0, timelapsePlaybackSpeed: 1, modal: { open: false, type: null, payload: null }, zoneEditor: { open: false, cameraId: null, draftZones: [], selectedZoneId: null, dragMode: null, isDrawing: false, startX: 0, startY: 0, currentRect: null }, compareSlider: 50, dateRange: '24h', cameraSearch: '', cameraStatus: 'all', activityCategory: 'all', activitySearch: '', alertSeverity: 'all', alertState: 'all', weatherExpanded: true, activityAutoScroll: true, compactCards: false }); await loadAllData(); renderShell(); bindEvents(); startBackgroundTasks(); state.initialized = true; state.root.classList.toggle('is-compact', state.ui.compactCards); } function destroy() { if (state.initialized || state.root || state.container) { clearManagedResources(); } stopTimelapsePlayback(); if (refs.modalRoot) { refs.modalRoot.innerHTML = ''; } if (state.container) { state.container.innerHTML = ''; } var style = document.getElementById(STYLE_ID); if (style) { style.remove(); } state.initialized = false; state.destroyed = true; state.containerId = null; state.container = null; state.root = null; refs.tabPanels = {}; refs.modalRoot = null; refs.timelapsePlayerCanvas = null; } window.BauGenioBaustellenueberwachung = { init: init, destroy: destroy, refresh: refresh }; })(); '); doc.close(); frame.contentWindow.focus(); frame.contentWindow.print(); } async function copyStampHtml() { var roomId = state.view.stampRoomId || state.selection.roomId; var room = state.indices.rooms[roomId]; if (!room) { showModuleToast('Kein Raum für den Stempel ausgewählt.', 'warning'); return; } var html = buildStampHtml(room, state.view.stampTemplate); try { await navigator.clipboard.writeText(html); showModuleToast('Raumstempel-HTML kopiert.', 'success'); } catch (error) { console.error(error); showModuleToast('Kopieren nicht möglich.', 'warning'); } } function resetDataset() { var okay = window.confirm('Den aktuellen lokalen Datenbestand verwerfen und Dummy-Daten neu erzeugen?'); if (!okay) { return; } state.data = createDummyDataset(); addAudit('Reset', 'system', 'Dummy-Daten', 'Datenbestand zurückgesetzt'); saveLocalSnapshot(); rebuildState(); render(); showModuleToast('Dummy-Daten neu geladen.', 'success'); } window.BauGenioRaumbuch = { init: init, destroy: destroy, refresh: refresh }; })(); ' ].join('')); printWindow.document.close(); printWindow.focus(); printWindow.print(); } // ========================================================================= // Kennzahlen / Selektoren // ========================================================================= function calculateDashboardMetrics() { var totalElevators = state.data.elevators.length; var openIncidents = getOpenIncidents().length; var inspectionsDueSoon = getDueInspections(30).length; var capexOpen = state.data.modernizations .filter(function(item) { return item.status !== 'Abgeschlossen'; }) .reduce(function(sum, item) { return sum + toNumber(item.capexEstimate); }, 0); return { totalElevators: totalElevators, openIncidents: openIncidents, inspectionsDueSoon: inspectionsDueSoon, capexOpen: capexOpen, energySavingsPotential: sumByField(getEnergyCandidates(), 'savingsPotentialEuro'), averageEnergyClass: calculateAverageEnergyClass(), serviceCompliance: calculateServiceCompliance(), slaBreaches: getSlaBreachIncidents().length, highRiskElevators: state.data.elevators.filter(function(item) { return item.riskLevel === 'Hoch' || item.riskLevel === 'Kritisch'; }).length }; } function calculateMaintenanceSummary() { return { activeContracts: countByField(state.data.contracts, 'status', 'Aktiv'), averageMonthlyCost: calculateAverage(state.data.contracts, 'priceMonthly'), roundTheClockContracts: state.data.contracts.filter(function(item) { return item.standby24h; }).length }; } function buildUpcomingTasks() { var items = []; state.data.elevators.forEach(function(elevator) { var serviceDays = daysUntil(elevator.nextServiceDate); var tuvDays = daysUntil(elevator.nextTuvDate); if (serviceDays <= 30) { items.push({ date: elevator.nextServiceDate, dateLabel: formatDate(elevator.nextServiceDate), title: elevator.plantNo + ' · Wartung', subtitle: elevator.name + ' · ' + elevator.building, badge: serviceDays < 0 ? 'Überfällig' : '≤ 30 Tage', tone: serviceDays < 0 ? 'danger' : 'warning' }); } if (tuvDays <= 45) { items.push({ date: elevator.nextTuvDate, dateLabel: formatDate(elevator.nextTuvDate), title: elevator.plantNo + ' · TÜV', subtitle: elevator.name + ' · ' + elevator.building, badge: tuvDays < 0 ? 'Überfällig' : 'Prüffrist', tone: tuvDays < 0 ? 'danger' : 'warning' }); } }); state.data.contracts.forEach(function(contract) { var days = daysUntil(contract.endDate); if (days <= 120) { items.push({ date: contract.endDate, dateLabel: formatDate(contract.endDate), title: contract.contractNo + ' · Vertragsreview', subtitle: getRelationLabel('elevators', contract.elevatorId) + ' · ' + contract.provider, badge: days < 0 ? 'Ausgelaufen' : 'Ablauf', tone: days < 0 ? 'danger' : 'accent' }); } }); state.data.modernizations.forEach(function(project) { if (project.status !== 'Abgeschlossen') { items.push({ date: project.targetStart, dateLabel: formatDate(project.targetStart), title: project.projectName, subtitle: project.reason + ' · ' + getRelationLabel('elevators', project.elevatorId), badge: project.priority || 'Mittel', tone: badgeColorForSet('priorityLevels', project.priority || 'Mittel') }); } }); return items.sort(function(a, b) { return compareDates(a.date, b.date); }).slice(0, 14); } function buildElevatorHealthRows() { return state.data.elevators.map(function(elevator) { var health = calculateElevatorHealth(elevator); return { id: elevator.id, label: elevator.plantNo + ' · ' + elevator.name, meta: elevator.building + ' · ' + elevator.status, score: health.score }; }).sort(function(a, b) { return a.score - b.score; }); } function calculateElevatorHealth(elevator) { var score = 100; var incidents = state.data.incidents.filter(function(item) { return item.elevatorId === elevator.id && item.status !== 'Gelöst'; }); var inspection = latestByDate(state.data.inspections.filter(function(item) { return item.elevatorId === elevator.id; }), 'dueDate'); var energy = state.data.energyAnalyses.find(function(item) { return item.elevatorId === elevator.id; }); if (elevator.status === 'Außer Betrieb') { score -= 45; } else if (elevator.status === 'Eingeschränkt') { score -= 25; } else if (elevator.status === 'In Modernisierung') { score -= 15; } if (elevator.riskLevel === 'Hoch') { score -= 12; } if (elevator.riskLevel === 'Kritisch') { score -= 20; } score -= incidents.length * 8; if (inspection) { if (inspection.status === 'Überfällig') { score -= 20; } if (inspection.result === 'Erhebliche Mängel') { score -= 18; } } if (energy) { if (energy.currentClass === 'E' || energy.currentClass === 'F') { score -= 8; } } if (daysUntil(elevator.nextServiceDate) < 0) { score -= 10; } if (daysUntil(elevator.nextTuvDate) < 0) { score -= 15; } score = Math.max(5, Math.min(100, score)); return { score: score }; } function buildIncidentSeverityDistribution() { return OPTION_SETS.incidentSeverities.map(function(item) { return { label: item.label, value: state.data.incidents.filter(function(incident) { return incident.status !== 'Gelöst' && incident.severity === item.value; }).length, tone: badgeColorForSet('incidentSeverities', item.value) }; }); } function getEnergyCandidates() { return state.data.energyAnalyses .filter(function(item) { return item.status !== 'Abgeschlossen' && calculateEnergySavingsPct(item) >= 10; }) .sort(function(a, b) { return toNumber(b.savingsPotentialEuro) - toNumber(a.savingsPotentialEuro); }); } function getOpenIncidents() { return state.data.incidents.filter(function(item) { return item.status !== 'Gelöst'; }).sort(function(a, b) { return compareDates(a.reactionDeadline, b.reactionDeadline); }); } function getSlaBreachIncidents() { var now = new Date(); return state.data.incidents.filter(function(item) { return item.status !== 'Gelöst' && item.reactionDeadline && new Date(item.reactionDeadline) < now; }); } function getDueInspections(days) { return state.data.inspections.filter(function(item) { return daysUntil(item.dueDate) <= days; }); } function getContractsExpiring(days) { return state.data.contracts.filter(function(item) { return daysUntil(item.endDate) <= days; }).sort(function(a, b) { return compareDates(a.endDate, b.endDate); }); } function getFilteredElevators() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.elevators]); return state.data.elevators.filter(function(item) { if (state.ui.filters.elevatorStatus && item.status !== state.ui.filters.elevatorStatus) { return false; } if (state.ui.filters.riskLevel && item.riskLevel !== state.ui.filters.riskLevel) { return false; } if (state.ui.filters.building && item.building !== state.ui.filters.building) { return false; } if (query && !matchesSearch(item, query, ['plantNo', 'name', 'building', 'manufacturer', 'liftType', 'city'])) { return false; } return true; }).sort(sortByField(state.ui.sort.field || 'name', state.ui.sort.dir || 'asc')); } function getFilteredContracts() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.maintenance]); return state.data.contracts.filter(function(item) { if (state.ui.filters.contractStatus && item.status !== state.ui.filters.contractStatus) { return false; } if (query && !matchesSearch(item, query, ['contractNo', 'provider', 'contractType', 'technician'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }); } function getFilteredInspections() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.inspections]); return state.data.inspections.filter(function(item) { if (state.ui.filters.inspectionStatus && item.status !== state.ui.filters.inspectionStatus) { return false; } if (query && !matchesSearch(item, query, ['inspectionType', 'inspectionBody', 'reportNo', 'inspector', 'result'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }); } function getFilteredIncidents() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.incidents]); return state.data.incidents.filter(function(item) { if (state.ui.filters.incidentStatus && item.status !== state.ui.filters.incidentStatus) { return false; } if (state.ui.filters.incidentSeverity && item.severity !== state.ui.filters.incidentSeverity) { return false; } if (query && !matchesSearch(item, query, ['ticketNo', 'category', 'status', 'description', 'assignedTo', 'supplier'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }).sort(function(a, b) { return compareDates(a.reportedAt, b.reportedAt) * -1; }); } function getFilteredModernizations() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.modernizations]); return state.data.modernizations.filter(function(item) { if (state.ui.filters.modernizationStatus && item.status !== state.ui.filters.modernizationStatus) { return false; } if (query && !matchesSearch(item, query, ['projectName', 'reason', 'scope', 'contractor', 'decisionStatus'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }); } function getFilteredEmergencyProtocols() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.emergency]); return state.data.emergencyProtocols.filter(function(item) { if (state.ui.filters.emergencyType && item.eventType !== state.ui.filters.emergencyType) { return false; } if (query && !matchesSearch(item, query, ['protocolNo', 'eventType', 'initiatedBy', 'serviceProvider', 'cause'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }); } function getFilteredEnergyAnalyses() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.energy]); return state.data.energyAnalyses.filter(function(item) { if (state.ui.filters.energyStatus && item.status !== state.ui.filters.energyStatus) { return false; } if (query && !matchesSearch(item, query, ['currentClass', 'projectedClass', 'recommendation', 'status'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }); } function getFilteredSpareParts() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.spareParts]); return state.data.spareParts.filter(function(item) { if (state.ui.filters.partCriticality && item.criticality !== state.ui.filters.partCriticality) { return false; } if (query && !matchesSearch(item, query, ['partNo', 'description', 'manufacturer', 'category', 'supplier'])) { return false; } return true; }); } function getFilteredRuntimeLogs() { var query = normalizeSearch([state.ui.search, state.ui.searchByTab.runtime]); return state.data.runtimeLogs.filter(function(item) { if (state.ui.filters.runtimeYear && String(item.year) !== String(state.ui.filters.runtimeYear)) { return false; } if (query && !matchesSearch(item, query, ['month', 'year', 'notes'])) { var elevatorLabel = getRelationLabel('elevators', item.elevatorId); if (normalizeString(elevatorLabel).indexOf(query) === -1) { return false; } } return true; }).sort(function(a, b) { if (a.year === b.year) { return toNumber(b.month) - toNumber(a.month); } return toNumber(b.year) - toNumber(a.year); }); } function paginateRows(entity, rows) { var normalizedEntity = normalizeEntityKey(entity); var page = state.ui.pages[normalizedEntity] || 1; var start = (page - 1) * state.ui.pageSize; return rows.slice(start, start + state.ui.pageSize); } function getSelectedElevator() { return findById(state.data.elevators, state.ui.selectedElevatorId); } function calculateEnergySavingsPct(item) { var baseline = toNumber(item.baselineKwhYear); var projected = toNumber(item.projectedKwhYear); if (!baseline || !projected) { return 0; } return Math.max(0, Math.round(((baseline - projected) / baseline) * 100)); } function estimateRoiYears(item) { var savings = toNumber(item.savingsPotentialEuro); if (!savings) { return 0; } return formatNumber(((savings * 8) / savings), 1); } function calculateAverageEnergyClass() { var mapping = { A: 5, B: 4, C: 3, D: 2, E: 1, F: 0 }; var reverse = { 5: 'A', 4: 'B', 3: 'C', 2: 'D', 1: 'E', 0: 'F' }; if (!state.data.elevators.length) { return '—'; } var sum = state.data.elevators.reduce(function(total, item) { return total + (mapping[item.energyClass] || 0); }, 0); var avg = Math.round(sum / state.data.elevators.length); return reverse[avg] || '—'; } function calculateServiceCompliance() { if (!state.data.elevators.length) { return 0; } var compliant = state.data.elevators.filter(function(item) { return daysUntil(item.nextServiceDate) >= 0; }).length; return Math.round((compliant / state.data.elevators.length) * 100); } function calculateAverage(rows, field) { if (!rows.length) { return 0; } var sum = rows.reduce(function(total, item) { return total + toNumber(item[field]); }, 0); return sum / rows.length; } function calculateSparePartInventoryValue(rows) { return (rows || []).reduce(function(sum, item) { return sum + (toNumber(item.stockQty) * toNumber(item.unitCost)); }, 0); } function getTopRuntimeElevators(rows) { return rows.slice().sort(function(a, b) { return toNumber(b.trips) - toNumber(a.trips); }).slice(0, 8); } function getAvailableRuntimeYears() { return getDistinctValues(state.data.runtimeLogs, 'year').sort(function(a, b) { return toNumber(b) - toNumber(a); }); } // ========================================================================= // Hilfsfunktionen // ========================================================================= function renderPlainCellValue(entity, row, column) { var formatter = column.formatter || 'text'; var raw = row[column.key]; if (formatter.indexOf('relation:') === 0) { return getRelationLabel(formatter.split(':')[1], raw); } if (formatter.indexOf('badge:') === 0) { var badgeSet = formatter.split(':')[1]; if (badgeSet === 'months') { return pad(raw, 2); } return formatValue(raw); } switch (formatter) { case 'date': return formatDate(raw); case 'datetime': return formatDateTime(raw); case 'number': return formatNumber(raw, 0); case 'currency': return formatCurrency(raw); case 'percent': return formatNumber(raw, 0) + '%'; case 'boolean': return raw ? 'Ja' : 'Nein'; default: return formatValue(raw); } } function getRelationLabel(entity, id) { var list = state.data[entity] || []; var item = list.find(function(entry) { return String(entry.id) === String(id); }); return item ? getDisplayLabel(entity, item) : '—'; } function getDisplayLabel(entity, item) { if (!item) { return '—'; } if (entity === 'elevators') { return item.plantNo + ' · ' + item.name; } if (entity === 'contracts') { return item.contractNo + ' · ' + item.provider; } if (entity === 'inspections') { return item.inspectionType + ' · ' + formatDate(item.dueDate); } if (entity === 'incidents') { return item.ticketNo + ' · ' + item.category; } if (entity === 'modernizations') { return item.projectName; } if (entity === 'emergencyProtocols') { return item.protocolNo || item.eventType; } if (entity === 'energyAnalyses') { return formatDate(item.analysisDate) + ' · ' + getRelationLabel('elevators', item.elevatorId); } if (entity === 'spareParts') { return item.partNo + ' · ' + item.description; } if (entity === 'runtimeLogs') { return item.year + '-' + pad(item.month, 2) + ' · ' + getRelationLabel('elevators', item.elevatorId); } return item.id; } function getDisplayField(entity) { switch (entity) { case 'elevators': return 'name'; case 'contracts': return 'contractNo'; case 'inspections': return 'reportNo'; case 'incidents': return 'ticketNo'; case 'modernizations': return 'projectName'; case 'emergencyProtocols': return 'protocolNo'; case 'energyAnalyses': return 'analysisDate'; case 'spareParts': return 'partNo'; case 'runtimeLogs': return 'year'; default: return 'id'; } } function badgeColorForSet(setKey, value) { var map = { elevatorStatuses: { 'Aktiv': 'success', 'Eingeschränkt': 'warning', 'Außer Betrieb': 'danger', 'In Modernisierung': 'accent', 'In Planung': 'neutral' }, riskLevels: { 'Niedrig': 'success', 'Mittel': 'warning', 'Hoch': 'danger', 'Kritisch': 'danger' }, contractStatuses: { 'Aktiv': 'success', 'Auslaufend': 'warning', 'Gekündigt': 'danger', 'Abgelaufen': 'danger', 'In Prüfung': 'accent' }, inspectionStatuses: { 'Geplant': 'accent', 'Überfällig': 'danger', 'Abgeschlossen': 'success', 'Nachverfolgung': 'warning' }, inspectionResults: { 'Ohne Mängel': 'success', 'Geringe Mängel': 'warning', 'Erhebliche Mängel': 'danger', 'Nicht bestanden': 'danger' }, defectClasses: { 'Keine': 'success', 'Klasse A': 'neutral', 'Klasse B': 'warning', 'Klasse C': 'danger' }, incidentSeverities: { 'Niedrig': 'success', 'Mittel': 'warning', 'Hoch': 'danger', 'Kritisch': 'danger' }, incidentStatuses: { 'Offen': 'warning', 'In Bearbeitung': 'accent', 'Eskaliert': 'danger', 'Gelöst': 'success' }, priorityLevels: { 'Niedrig': 'success', 'Mittel': 'warning', 'Hoch': 'danger', 'Kritisch': 'danger' }, modernizationStatuses: { 'Geplant': 'accent', 'In Umsetzung': 'warning', 'Abgeschlossen': 'success', 'Gestoppt': 'danger' }, decisionStatuses: { 'Offen': 'warning', 'In Prüfung': 'accent', 'Freigegeben': 'success', 'Zurückgestellt': 'neutral' }, responseQualities: { 'Sehr gut': 'success', 'Gut': 'accent', 'Verbesserungsbedarf': 'warning', 'Kritisch': 'danger' }, energyStatuses: { 'Identifiziert': 'warning', 'In Umsetzung': 'accent', 'Abgeschlossen': 'success', 'Zurückgestellt': 'neutral' }, energyClasses: { 'A': 'success', 'B': 'success', 'C': 'accent', 'D': 'warning', 'E': 'danger', 'F': 'danger' }, criticalityLevels: { 'Niedrig': 'success', 'Mittel': 'warning', 'Hoch': 'danger', 'Kritisch': 'danger' }, months: { '1': 'neutral', '2': 'neutral', '3': 'neutral', '4': 'neutral', '5': 'neutral', '6': 'neutral', '7': 'neutral', '8': 'neutral', '9': 'neutral', '10': 'neutral', '11': 'neutral', '12': 'neutral' } }; return (map[setKey] && map[setKey][String(value)]) || 'neutral'; } function sortByField(field, dir) { return function(a, b) { var av = a[field]; var bv = b[field]; if (isDateValue(av) || isDateValue(bv)) { return compareDates(av, bv) * (dir === 'desc' ? -1 : 1); } if (!isNaN(av) && !isNaN(bv)) { return (toNumber(av) - toNumber(bv)) * (dir === 'desc' ? -1 : 1); } return String(av || '').localeCompare(String(bv || ''), 'de') * (dir === 'desc' ? -1 : 1); }; } function latestByDate(items, field) { if (!items.length) { return null; } return items.slice().sort(function(a, b) { return compareDates(a[field], b[field]) * -1; })[0]; } function countByField(rows, field, value) { return (rows || []).filter(function(item) { return item[field] === value; }).length; } function sumByField(rows, field) { return (rows || []).reduce(function(sum, item) { return sum + toNumber(item[field]); }, 0); } function findById(list, id) { return (list || []).find(function(item) { return String(item.id) === String(id); }) || null; } function deepClone(input) { return JSON.parse(JSON.stringify(input)); } function matchesSearch(item, query, fields) { var haystack = fields.map(function(field) { var value = item[field]; if (Array.isArray(value)) { return value.join(' '); } return value; }).join(' '); return normalizeString(haystack).indexOf(query) > -1; } function normalizeSearch(values) { return normalizeString((values || []).filter(Boolean).join(' ')); } function normalizeString(value) { return String(value || '') .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .trim(); } function getDistinctValues(rows, field) { var map = {}; (rows || []).forEach(function(item) { if (item[field] !== '' && item[field] !== null && item[field] !== void 0) { map[item[field]] = true; } }); return Object.keys(map); } function hoursBetween(start, end) { if (!start || !end) { return 0; } var s = new Date(start); var e = new Date(end); return Math.max(0, Math.round(((e.getTime() - s.getTime()) / 3600000) * 10) / 10); } function daysUntil(dateValue) { if (!dateValue) { return 9999; } var now = new Date(); var date = new Date(dateValue); var diff = date.getTime() - now.getTime(); return Math.floor(diff / 86400000); } function compareDates(a, b) { var da = a ? new Date(a).getTime() : 0; var db = b ? new Date(b).getTime() : 0; return da - db; } function addDays(baseDate, days) { var date = new Date(baseDate); date.setDate(date.getDate() + days); return date; } function toIsoDate(date) { if (!date) { return ''; } var d = new Date(date); return d.toISOString().slice(0, 10); } function addMinutesToIso(value, minutes) { var d = new Date(value); d = new Date(d.getTime() + (minutes * 60000)); return d.toISOString().slice(0, 16); } function isDateValue(value) { if (!value || typeof value !== 'string') { return false; } return /^\d{4}-\d{2}-\d{2}/.test(value); } function toNumber(value) { if (value === '' || value === null || value === void 0) { return 0; } if (typeof value === 'number') { return value; } var normalized = String(value).replace(/\./g, '').replace(',', '.'); var parsed = parseFloat(normalized); return isNaN(parsed) ? 0 : parsed; } function formatValue(value) { if (Array.isArray(value)) { return value.join(', '); } if (value === null || value === void 0 || value === '') { return '—'; } return String(value); } function formatNumber(value, digits) { var number = toNumber(value); return number.toLocaleString('de-DE', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); } function formatCurrency(value) { var number = toNumber(value); return number.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', minimumFractionDigits: 0, maximumFractionDigits: 0 }); } function formatDate(value) { if (!value) { return '—'; } var date = new Date(value); if (isNaN(date.getTime())) { return '—'; } return date.toLocaleDateString('de-DE'); } function formatDateTime(value) { if (!value) { return '—'; } var date = new Date(value); if (isNaN(date.getTime())) { return '—'; } return date.toLocaleString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } function pad(value, length) { var stringValue = String(value); while (stringValue.length < length) { stringValue = '0' + stringValue; } return stringValue; } function generateId(prefix) { var random = Math.random().toString(36).slice(2, 9).toUpperCase(); return prefix + '-' + Date.now().toString(36).toUpperCase() + '-' + random; } function safeHtml(value) { if (typeof window.escapeHtml === 'function') { return window.escapeHtml(String(value === null || value === void 0 ? '' : value)); } return String(value === null || value === void 0 ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function safeAttr(value) { return safeHtml(value); } function safeToast(message, type) { if (typeof window.showToast === 'function') { window.showToast(message, type || 'info'); } else { console.log('[Toast][' + (type || 'info') + '] ' + message); } } function formatValueForCsv(value) { return String(value === null || value === void 0 ? '' : value).replace(/\n/g, ' '); } // ========================================================================= // Styling // ========================================================================= function injectStyles() { var existing = document.getElementById(STYLE_ID); if (existing) { return; } var style = document.createElement('style'); style.id = STYLE_ID; style.textContent = getStyles(); document.head.appendChild(style); } function getStyles() { return [ '.bgam-page{color:var(--text-primary);background:var(--bg-primary);font-family:Inter,Arial,sans-serif;display:flex;flex-direction:column;gap:16px;padding:16px;box-sizing:border-box;}', '.bgam-header{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;}', '.bgam-header-left{flex:1 1 420px;min-width:280px;}', '.bgam-header-right{display:grid;grid-template-columns:repeat(2,minmax(160px,1fr));gap:12px;flex:1 1 360px;}', '.bgam-kicker{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-secondary);margin-bottom:6px;}', '.bgam-title{margin:0;font-size:28px;line-height:1.15;}', '.bgam-subtitle{color:var(--text-secondary);margin-top:8px;max-width:900px;}', '.bgam-header-meta{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;font-size:12px;color:var(--text-secondary);}', '.bgam-toolbar,.bgam-subtoolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;background:var(--bg-card);border:1px solid var(--border-color);border-radius:14px;padding:12px;}', '.bgam-toolbar-left,.bgam-toolbar-right,.bgam-filter-row,.bgam-inline-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}', '.bgam-layout{display:grid;grid-template-columns:320px 1fr;gap:16px;align-items:start;}', '.bgam-sidebar{display:flex;flex-direction:column;gap:16px;position:sticky;top:16px;}', '.bgam-main{display:flex;flex-direction:column;gap:16px;}', '.bgam-content{display:flex;flex-direction:column;gap:16px;}', '.bgam-tabs{display:flex;gap:8px;overflow:auto;background:var(--bg-card);border:1px solid var(--border-color);border-radius:14px;padding:8px;}', '.bgam-tab{border:none;background:transparent;color:var(--text-secondary);padding:10px 12px;border-radius:10px;cursor:pointer;display:flex;align-items:center;gap:8px;white-space:nowrap;font-weight:600;}', '.bgam-tab:hover,.bgam-tab.is-active{background:var(--bg-secondary);color:var(--text-primary);}', '.bgam-panel{background:var(--bg-card);border:1px solid var(--border-color);border-radius:16px;padding:16px;box-sizing:border-box;}', '.bgam-panel-inner{padding:0;overflow:hidden;}', '.bgam-panel-header{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}', '.bgam-panel-header h3{margin:0;font-size:18px;}', '.bgam-panel-caption{color:var(--text-secondary);font-size:12px;}', '.bgam-panel-separator{height:1px;background:var(--border-color);margin:14px 0;}', '.bgam-grid{display:grid;gap:16px;}', '.bgam-grid-2-1{grid-template-columns:2fr 1fr;}', '.bgam-stack{display:flex;flex-direction:column;gap:16px;}', '.bgam-kpi-card,.bgam-wide-kpi,.bgam-mini-stat,.bgam-kpi-strip-item{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:14px;padding:12px;}', '.bgam-kpi-card{min-height:100px;display:flex;flex-direction:column;justify-content:space-between;}', '.bgam-wide-kpi{min-height:110px;display:flex;flex-direction:column;justify-content:space-between;}', '.bgam-kpi-label,.bgam-wide-kpi-label,.bgam-kpi-strip-label{font-size:12px;color:var(--text-secondary);}', '.bgam-kpi-value{font-size:26px;font-weight:800;line-height:1.1;}', '.bgam-wide-kpi-value{font-size:24px;font-weight:800;}', '.bgam-kpi-help,.bgam-wide-kpi-help{font-size:12px;color:var(--text-secondary);}', '.bgam-kpi-grid{display:grid;grid-template-columns:repeat(3,minmax(180px,1fr));gap:12px;}', '.bgam-kpi-strip{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin-bottom:12px;}', '.bgam-kpi-strip-value{font-size:22px;font-weight:800;margin-top:6px;}', '.bgam-sidebar-stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}', '.bgam-mini-stat{display:flex;justify-content:space-between;align-items:center;}', '.bgam-mini-stat-label{font-size:12px;color:var(--text-secondary);}', '.bgam-mini-stat-value{font-size:18px;}', '.bgam-quick-actions{display:grid;grid-template-columns:1fr;gap:10px;}', '.bgam-quick-action{display:flex;justify-content:space-between;align-items:center;border:1px solid var(--border-color);background:var(--bg-secondary);padding:10px 12px;border-radius:12px;cursor:pointer;color:inherit;}', '.bgam-elevator-list{display:flex;flex-direction:column;gap:8px;max-height:360px;overflow:auto;}', '.bgam-elevator-item{display:flex;justify-content:space-between;gap:8px;border:1px solid var(--border-color);background:var(--bg-secondary);padding:10px;border-radius:12px;color:inherit;cursor:pointer;text-align:left;}', '.bgam-elevator-item.is-active{border-color:var(--color-accent);box-shadow:0 0 0 1px rgba(0,0,0,.03) inset;}', '.bgam-elevator-item-main{display:flex;flex-direction:column;gap:4px;}', '.bgam-elevator-item-name{font-weight:700;}', '.bgam-elevator-item-sub{font-size:12px;color:var(--text-secondary);}', '.bgam-elevator-item-side{display:flex;align-items:center;gap:8px;}', '.bgam-dot{display:inline-block;width:8px;height:8px;border-radius:50%;}', '.bgam-dot-danger{background:var(--color-danger);}', '.bgam-dot-success{background:var(--color-success);}', '.bgam-selected-card{display:flex;flex-direction:column;gap:10px;}', '.bgam-selected-row{display:flex;justify-content:space-between;gap:10px;font-size:13px;}', '.bgam-progress{width:100%;height:8px;background:var(--bg-secondary);border-radius:999px;overflow:hidden;border:1px solid var(--border-color);}', '.bgam-progress span{display:block;height:100%;background:var(--color-accent);}', '.bgam-input-wrap{position:relative;display:flex;align-items:center;min-width:240px;flex:1 1 260px;}', '.bgam-input-icon{position:absolute;left:12px;color:var(--text-secondary);pointer-events:none;}', '.bgam-input,.bgam-select,.bgam-textarea{width:100%;border:1px solid var(--border-color);background:var(--bg-secondary);color:var(--text-primary);border-radius:12px;padding:11px 12px;box-sizing:border-box;font:inherit;}', '.bgam-input{padding-left:36px;}', '.bgam-textarea{min-height:100px;resize:vertical;}', '.bgam-btn,.bgam-page-btn,.bgam-link-btn,.bgam-icon-btn{font:inherit;cursor:pointer;}', '.bgam-btn{border:none;border-radius:12px;padding:10px 14px;font-weight:700;}', '.bgam-btn-primary{background:var(--color-accent);color:#fff;}', '.bgam-btn-secondary{background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-color);}', '.bgam-btn-danger{background:var(--color-danger);color:#fff;}', '.bgam-link-btn{border:none;background:none;color:var(--color-accent);padding:0;font-weight:700;}', '.bgam-icon-btn{border:none;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:10px;padding:8px;display:inline-flex;align-items:center;justify-content:center;}', '.bgam-table-wrap{overflow:auto;}', '.bgam-table{width:100%;border-collapse:separate;border-spacing:0;}', '.bgam-table th,.bgam-table td{padding:12px;border-bottom:1px solid var(--border-color);text-align:left;vertical-align:top;white-space:nowrap;}', '.bgam-table th{position:sticky;top:0;background:var(--bg-card);z-index:1;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-secondary);}', '.bgam-col-actions{min-width:190px;}', '.bgam-actions{display:flex;gap:6px;align-items:center;}', '.bgam-pagination{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:12px 16px;}', '.bgam-pagination-buttons{display:flex;gap:6px;flex-wrap:wrap;}', '.bgam-page-btn{border:1px solid var(--border-color);background:var(--bg-secondary);border-radius:10px;padding:8px 10px;}', '.bgam-page-btn.is-active{background:var(--color-accent);color:#fff;border-color:var(--color-accent);}', '.bgam-detail-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:12px;}', '.bgam-detail-kicker{font-size:12px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.08em;}', '.bgam-detail-title{font-size:22px;font-weight:800;}', '.bgam-detail-subtitle{color:var(--text-secondary);margin-top:4px;}', '.bgam-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}', '.bgam-detail-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:14px;padding:14px;}', '.bgam-detail-card-title{font-size:14px;font-weight:800;margin-bottom:10px;}', '.bgam-detail-row{display:flex;justify-content:space-between;gap:10px;padding:6px 0;border-bottom:1px dashed var(--border-color);font-size:13px;}', '.bgam-detail-row:last-child{border-bottom:none;padding-bottom:0;}', '.bgam-tag-list{display:flex;gap:8px;flex-wrap:wrap;}', '.bgam-tag{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--border-color);font-size:12px;}', '.bgam-note-block{margin-top:12px;padding:12px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:12px;white-space:pre-wrap;}', '.bgam-list{display:flex;flex-direction:column;gap:10px;}', '.bgam-list-item{display:flex;justify-content:space-between;gap:12px;border:1px solid var(--border-color);background:var(--bg-secondary);border-radius:12px;padding:12px;}', '.bgam-list-main{display:flex;flex-direction:column;gap:4px;}', '.bgam-list-title{font-weight:700;}', '.bgam-list-subtitle,.bgam-list-date,.bgam-muted{font-size:12px;color:var(--text-secondary);}', '.bgam-list-side{display:flex;flex-direction:column;gap:6px;align-items:flex-end;}', '.bgam-health-table,.bgam-heat-list,.bgam-energy-rank-item,.bgam-runtime-peak-item,.bgam-correlation-item{display:flex;flex-direction:column;gap:10px;}', '.bgam-health-row,.bgam-heat-item,.bgam-defect-card,.bgam-risk-card,.bgam-energy-rank-item,.bgam-roi-card,.bgam-shortage-card,.bgam-quickwin-card,.bgam-lesson-card,.bgam-runtime-peak-item,.bgam-correlation-item{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:12px;padding:12px;}', '.bgam-health-row{display:grid;grid-template-columns:1fr 180px;gap:12px;align-items:center;}', '.bgam-health-title{font-weight:700;}', '.bgam-health-meta{font-size:12px;color:var(--text-secondary);margin-top:4px;}', '.bgam-health-score{display:flex;flex-direction:column;gap:6px;}', '.bgam-heat-head,.bgam-candidate-head,.bgam-risk-head,.bgam-defect-footer,.bgam-defect-title,.bgam-escalation-item,.bgam-roi-head,.bgam-shortage-head,.bgam-quickwin-footer,.bgam-energy-rank-head,.bgam-runtime-peak-head,.bgam-correlation-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;flex-wrap:wrap;}', '.bgam-candidate-item,.bgam-radar-item,.bgam-escalation-item,.bgam-quickwin-card,.bgam-report-card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:12px;padding:12px;}', '.bgam-status-groups{display:flex;flex-direction:column;gap:16px;}', '.bgam-status-title{font-weight:800;margin-bottom:10px;}', '.bgam-bar-item{display:flex;flex-direction:column;gap:6px;margin-bottom:10px;}', '.bgam-bar-head{display:flex;justify-content:space-between;gap:12px;font-size:13px;}', '.bgam-bar{width:100%;height:8px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--border-color);overflow:hidden;}', '.bgam-bar span{display:block;height:100%;background:var(--color-accent);}', '.bgam-badge{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:999px;font-size:12px;font-weight:700;border:1px solid transparent;white-space:nowrap;}', '.bgam-badge-success{background:rgba(28,184,65,.12);color:var(--color-success);border-color:rgba(28,184,65,.22);}', '.bgam-badge-warning{background:rgba(255,179,0,.12);color:var(--color-warning);border-color:rgba(255,179,0,.22);}', '.bgam-badge-danger{background:rgba(214,68,68,.12);color:var(--color-danger);border-color:rgba(214,68,68,.22);}', '.bgam-badge-accent{background:rgba(41,98,255,.12);color:var(--color-accent);border-color:rgba(41,98,255,.22);}', '.bgam-badge-neutral{background:rgba(127,127,127,.12);color:var(--text-secondary);border-color:rgba(127,127,127,.22);}', '.bgam-tone-success{--bgam-tone:var(--color-success);}', '.bgam-tone-warning{--bgam-tone:var(--color-warning);}', '.bgam-tone-danger{--bgam-tone:var(--color-danger);}', '.bgam-tone-accent{--bgam-tone:var(--color-accent);}', '.bgam-tone-neutral{--bgam-tone:var(--text-secondary);}', '.bgam-kpi-card,.bgam-wide-kpi,.bgam-mini-stat,.bgam-kpi-strip-item,.bgam-quick-action{box-shadow:inset 0 0 0 1px rgba(255,255,255,.01);}', '.bgam-loading{display:grid;place-items:center;min-height:480px;background:var(--bg-primary);}', '.bgam-loading-card{background:var(--bg-card);border:1px solid var(--border-color);padding:32px;border-radius:18px;display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;max-width:420px;}', '.bgam-spinner{width:44px;height:44px;border:4px solid var(--border-color);border-top-color:var(--color-accent);border-radius:50%;animation:bgamSpin 1s linear infinite;}', '@keyframes bgamSpin{to{transform:rotate(360deg);}}', '.bgam-loading-title{font-size:18px;font-weight:800;}', '.bgam-loading-text{color:var(--text-secondary);}', '.bgam-empty-state,.bgam-empty-inline{padding:18px;border:1px dashed var(--border-color);border-radius:12px;color:var(--text-secondary);background:var(--bg-secondary);}', '.bgam-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:999;}', '.bgam-modal{position:fixed;z-index:1000;left:50%;top:50%;transform:translate(-50%,-50%);width:min(1100px,92vw);max-height:88vh;overflow:auto;background:var(--bg-card);border:1px solid var(--border-color);border-radius:18px;box-shadow:0 18px 70px rgba(0,0,0,.35);}', '.bgam-modal-header{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;border-bottom:1px solid var(--border-color);position:sticky;top:0;background:var(--bg-card);z-index:1;}', '.bgam-modal-header h3{margin:0;font-size:20px;}', '.bgam-modal-body{padding:20px;}', '.bgam-form{display:flex;flex-direction:column;gap:18px;}', '.bgam-fieldset{border:1px solid var(--border-color);border-radius:14px;padding:16px;}', '.bgam-fieldset legend{padding:0 8px;font-weight:800;color:var(--text-primary);}', '.bgam-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}', '.bgam-form-field{display:flex;flex-direction:column;gap:6px;}', '.bgam-form-label{font-size:12px;color:var(--text-secondary);font-weight:700;}', '.bgam-required{color:var(--color-danger);margin-left:4px;}', '.bgam-field-full{grid-column:1 / -1;}', '.bgam-checkbox-row{flex-direction:row;align-items:center;gap:10px;}', '.bgam-checkbox{width:18px;height:18px;}', '.bgam-form-actions{display:flex;justify-content:flex-end;gap:10px;position:sticky;bottom:0;background:var(--bg-card);padding-top:12px;}', '.bgam-confirm{position:fixed;z-index:1001;left:50%;top:50%;transform:translate(-50%,-50%);width:min(560px,92vw);background:var(--bg-card);border:1px solid var(--border-color);border-radius:18px;padding:24px;box-shadow:0 18px 70px rgba(0,0,0,.35);}', '.bgam-confirm-title{font-size:20px;font-weight:800;margin-bottom:10px;}', '.bgam-confirm-text{color:var(--text-secondary);margin-bottom:18px;}', '.bgam-confirm-actions{display:flex;justify-content:flex-end;gap:10px;}', '.bgam-detail-modal-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;}', '.bgam-detail-modal-row{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:12px;padding:12px;}', '.bgam-detail-modal-label{font-size:12px;color:var(--text-secondary);margin-bottom:6px;}', '.bgam-detail-modal-value{font-weight:700;white-space:pre-wrap;}', '.bgam-report-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;}', '.bgam-report-card{display:flex;flex-direction:column;gap:12px;}', '.bgam-report-title{font-size:18px;font-weight:800;}', '.bgam-report-text{color:var(--text-secondary);min-height:48px;}', '.bgam-report-output{min-height:220px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:14px;padding:18px;}', '.bgam-report-doc h2,.bgam-report-doc h3{margin-top:0;}', '.bgam-template-box,.bgam-lesson-card,.bgam-defect-card,.bgam-shortage-card,.bgam-risk-card,.bgam-roi-card,.bgam-quickwin-card,.bgam-report-card,.bgam-candidate-item,.bgam-radar-item{display:flex;flex-direction:column;gap:8px;}', '.bgam-template-title{font-weight:800;}', '.bgam-template-content{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:12px;padding:12px;white-space:pre-wrap;}', '.bgam-escalation-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;}', '.bgam-pipeline{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;overflow:auto;}', '.bgam-pipeline-col{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:14px;min-width:150px;}', '.bgam-pipeline-head{padding:12px;font-weight:800;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;}', '.bgam-pipeline-body{padding:12px;display:flex;flex-direction:column;gap:10px;}', '.bgam-pipeline-card{background:var(--bg-card);border:1px solid var(--border-color);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:6px;}', '.bgam-roi-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;}', '.bgam-roi-grid div{background:var(--bg-card);border:1px solid var(--border-color);border-radius:10px;padding:10px;display:flex;flex-direction:column;gap:4px;}', '.bgam-checklist{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:10px;}', '.bgam-hint-list{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:8px;}', '.bgam-density-compact .bgam-table th,.bgam-density-compact .bgam-table td{padding:8px;}', '.bgam-density-compact .bgam-panel{padding:12px;}', '.bgam-density-compact .bgam-kpi-grid{grid-template-columns:repeat(2,minmax(180px,1fr));}', '@media (max-width:1280px){.bgam-layout{grid-template-columns:1fr;}.bgam-sidebar{position:static;}.bgam-grid-2-1{grid-template-columns:1fr;}.bgam-kpi-grid,.bgam-report-grid,.bgam-escalation-summary,.bgam-form-grid,.bgam-detail-grid,.bgam-detail-modal-grid,.bgam-kpi-strip,.bgam-header-right{grid-template-columns:repeat(2,minmax(0,1fr));}}', '@media (max-width:860px){.bgam-header-right,.bgam-kpi-grid,.bgam-kpi-strip,.bgam-report-grid,.bgam-escalation-summary,.bgam-form-grid,.bgam-detail-grid,.bgam-detail-modal-grid{grid-template-columns:1fr;}.bgam-pipeline{grid-template-columns:repeat(3,minmax(220px,1fr));}.bgam-health-row{grid-template-columns:1fr;}.bgam-table th,.bgam-table td{white-space:normal;}.bgam-col-actions{min-width:160px;}}', '@media (max-width:560px){.bgam-page{padding:12px;}.bgam-toolbar,.bgam-subtoolbar,.bgam-panel{border-radius:12px;}.bgam-modal{width:96vw;}.bgam-confirm{width:94vw;}}' ].join(''); } // ========================================================================= // Export // ========================================================================= window.BauGenioAufzugsmanagement = { init: init, destroy: destroy, refresh: refresh }; })(); '); printWindow.document.close(); printWindow.print(); } showToast('Fallback-Export über Druckansicht erstellt', 'warning'); } } } async function exportCurrentView(format) { const map = { dashboard: 'kataster', kataster: 'kataster', begruenung: 'zustand', pv: 'pv', wartung: 'wartung', berichte: state.ui.reportPreviewType }; await exportReport(map[state.activeView] || 'kataster', format); } async function createDefaultReminders() { const upcoming = state.data.maintenance.filter(function(entry) { return entry.status !== 'erledigt' && diffDays(new Date(), entry.plannedDate) <= 30; }); if (!upcoming.length) { showToast('Keine fälligen Wartungen für Erinnerungen gefunden', 'warning'); return; } for (const item of upcoming) { await createReminder({ roofId: item.roofId, maintenanceId: item.id, title: 'Wartungserinnerung: ' + item.title, remindAt: item.plannedDate, channel: 'system' }); } showToast('Wartungserinnerungen angelegt', 'success'); } function calculatePvLayout() { if (!state.modalEl) { return; } const roofId = qs('[data-role="pv-layout-roof"]', state.modalEl)?.value; const modulePowerWp = toInteger(qs('[data-role="pv-layout-module-power"]', state.modalEl)?.value, 430); const moduleArea = toNumber(qs('[data-role="pv-layout-module-area"]', state.modalEl)?.value, 1.95); const edgeDistance = toNumber(qs('[data-role="pv-layout-edge"]', state.modalEl)?.value, 0.5); const roof = state.derived.roofMap[roofId]; const target = qs('[data-role="pv-layout-result"]', state.modalEl); if (!roof || !target) { return; } const basePotential = computePvPotentialForRoof(roof); const edgeLossFactor = clamp(1 - ((edgeDistance * 2) / Math.max(roof.area / 10, 5)), 0.5, 1); const netArea = basePotential.usableArea * edgeLossFactor; const modules = Math.floor(netArea / moduleArea); const installedKw = round((modules * modulePowerWp) / 1000, 2); const yieldData = computePvYieldForecast({ installedKw: installedKw, orientation: roof.orientation, slope: roof.slope, shadeFactor: roof.shadeFactor }); const occupancyPercent = roof.usableArea ? round((modules * moduleArea / roof.usableArea) * 100, 1) : 0; target.innerHTML = [ '
', '
' + formatInteger(modules) + '
Module
', '
' + formatKw(installedKw) + '
Installierte Leistung
', '
' + formatKwh(yieldData.annualYieldKwh) + '
Jahresertrag
', '
' + formatPercent(occupancyPercent) + '
Belegung
', '
', '
', '
Annahme: Randabstände, Verschattung und Layoutfaktor sind heuristisch. Für finale Auslegung sind Brandschutz, Statik, Wind-/Sogzone und DC-Stringplanung separat zu prüfen.
' ].join(''); } function switchView(view) { state.activeView = view || DEFAULT_VIEW; persistUiState(); buildDerivedData(); renderContent(); } function resetFilters() { state.filters = { search: '', buildingId: '', roofType: '', condition: '', yearFrom: '', yearTo: '', pv: '', greening: '', priority: '', status: '', onlyOpenIssues: false, onlyUpcomingMaintenance: false }; buildDerivedData(); renderContent(); } function updateFilter(name, value) { if (!(name in state.filters)) { return; } state.filters[name] = value; persistUiState(); buildDerivedData(); renderViewOnly(); } function toggleRoofCard(id) { state.ui.expandedRoofIds[id] = !state.ui.expandedRoofIds[id]; renderViewOnly(); } async function handleFormSubmit(event) { const form = event.target; if (!(form instanceof HTMLFormElement)) { return; } const formName = form.dataset.form; if (!formName) { return; } event.preventDefault(); state.saving = true; try { if (formName === 'roof') { const mode = form.dataset.mode || 'create'; const payload = normalizeRoofPayload(form, serializeForm(form)); await saveRoof(payload, mode); closeModal(); return; } if (formName === 'greening') { const mode = form.dataset.mode || 'create'; const payload = normalizeGreeningPayload(serializeForm(form)); await saveGreeningPlan(payload, mode); closeModal(); return; } if (formName === 'pv') { const mode = form.dataset.mode || 'create'; const payload = normalizePvPayload(serializeForm(form)); await savePvSystem(payload, mode); closeModal(); return; } if (formName === 'maintenance') { const mode = form.dataset.mode || 'create'; const payload = normalizeMaintenancePayload(serializeForm(form)); await saveMaintenance(payload, mode); closeModal(); return; } if (formName === 'inspection') { const mode = form.dataset.mode || 'create'; const payload = normalizeInspectionPayload(serializeForm(form)); await saveInspection(payload, mode); closeModal(); return; } if (formName === 'issue') { const mode = form.dataset.mode || 'create'; const payload = normalizeIssuePayload(serializeForm(form)); await saveIssue(payload, mode); closeModal(); return; } if (formName === 'document') { const formData = new FormData(form); await saveDocument(formData); closeModal(); } } catch (error) { showToast(error.message || 'Fehler beim Speichern', 'danger'); } finally { state.saving = false; } } function handleRootInput(event) { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const name = target.getAttribute('name'); if (name === 'search') { updateFilter('search', target.value || ''); } } function handleRootChange(event) { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const name = target.getAttribute('name'); if (!name) { return; } if (name in state.filters) { if (target.type === 'checkbox') { updateFilter(name, target.checked); } else { updateFilter(name, target.value || ''); } } } async function executeConfirmAction() { if (!state.ui.confirmContext) { closeModal(); return; } const ctx = state.ui.confirmContext; if (ctx.action === 'delete-roof') { await deleteRoof(ctx.payload.id); } if (ctx.action === 'delete-greening') { await deleteGreeningPlan(ctx.payload.id); } if (ctx.action === 'delete-pv') { await deletePvSystem(ctx.payload.id); } if (ctx.action === 'delete-maintenance') { await deleteMaintenance(ctx.payload.id); } if (ctx.action === 'delete-inspection') { await deleteInspection(ctx.payload.id); } if (ctx.action === 'delete-issue') { await deleteIssue(ctx.payload.id); } if (ctx.action === 'delete-document') { await deleteDocument(ctx.payload.id); } state.ui.confirmContext = null; closeModal(); } async function handleRootClick(event) { const trigger = event.target.closest('[data-action]'); if (!trigger) { if (event.target === state.modalEl) { closeModal(); } return; } const action = trigger.dataset.action; const id = trigger.dataset.id || ''; const mode = trigger.dataset.mode || 'create'; const view = trigger.dataset.view || ''; const type = trigger.dataset.type || ''; const format = trigger.dataset.format || ''; const roofId = trigger.dataset.roofId || ''; if (action === 'switch-view') { switchView(view); return; } if (action === 'refresh-module') { await refresh(); return; } if (action === 'quick-create') { const quick = getQuickCreateAction(); if (quick.action === 'open-roof-modal') { openModal(roofFormModal('create')); return; } if (quick.action === 'open-greening-modal') { openModal(greeningFormModal('create')); return; } if (quick.action === 'open-pv-modal') { openModal(pvFormModal('create')); return; } if (quick.action === 'open-maintenance-modal') { openModal(maintenanceFormModal('create')); return; } if (quick.action === 'select-report-preview') { state.ui.reportPreviewType = quick.type; buildDerivedData(); renderContent(); return; } } if (action === 'toggle-roof') { toggleRoofCard(id); return; } if (action === 'reset-filters') { resetFilters(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'open-roof-modal') { const roof = mode === 'edit' ? state.derived.roofMap[id] : null; openModal(roofFormModal(mode, roof)); return; } if (action === 'open-greening-modal') { const plan = mode === 'edit' ? state.data.greeningPlans.find(function(item) { return item.id === id; }) : null; openModal(greeningFormModal(mode, plan)); return; } if (action === 'open-pv-modal') { const system = mode === 'edit' ? state.data.pvSystems.find(function(item) { return item.id === id; }) : null; openModal(pvFormModal(mode, system)); return; } if (action === 'open-maintenance-modal') { const entry = mode === 'edit' ? state.data.maintenance.find(function(item) { return item.id === id; }) : null; openModal(maintenanceFormModal(mode, entry, trigger.dataset.category || 'regelwartung')); return; } if (action === 'open-inspection-modal') { const entry = mode === 'edit' ? state.data.inspections.find(function(item) { return item.id === id; }) : null; openModal(inspectionFormModal(mode, entry)); return; } if (action === 'open-issue-modal') { const issue = mode === 'edit' ? state.data.issues.find(function(item) { return item.id === id; }) : null; openModal(issueFormModal(mode, issue)); return; } if (action === 'open-document-modal') { openModal(documentFormModal(roofId)); return; } if (action === 'open-pv-layout-modal') { openModal(pvLayoutModal()); return; } if (action === 'calculate-pv-layout') { calculatePvLayout(); return; } if (action === 'add-layer') { const host = qs('[data-role="layer-container"]', state.modalEl); if (host) { host.insertAdjacentHTML('beforeend', renderLayerRows([{ label: '', material: '', thickness: '', note: '' }])); } return; } if (action === 'remove-layer') { const row = trigger.closest('[data-layer-row]'); if (row) { row.remove(); } return; } if (action === 'confirm-delete-roof') { openModal(confirmModal('Dachfläche löschen', 'Die Dachfläche und verknüpfte Unterdaten werden gelöscht. Fortfahren?', 'delete-roof', { id: id })); return; } if (action === 'confirm-delete-greening') { openModal(confirmModal('Begrünungsplan löschen', 'Der Begrünungsplan wird entfernt. Fortfahren?', 'delete-greening', { id: id })); return; } if (action === 'confirm-delete-pv') { openModal(confirmModal('PV-Anlage löschen', 'Die PV-Anlage wird entfernt. Fortfahren?', 'delete-pv', { id: id })); return; } if (action === 'confirm-delete-maintenance') { openModal(confirmModal('Wartung löschen', 'Der Wartungseintrag wird gelöscht. Fortfahren?', 'delete-maintenance', { id: id })); return; } if (action === 'confirm-delete-inspection') { openModal(confirmModal('Inspektion löschen', 'Der Inspektionseintrag wird gelöscht. Fortfahren?', 'delete-inspection', { id: id })); return; } if (action === 'confirm-delete-issue') { openModal(confirmModal('Mangel löschen', 'Der Mangel wird gelöscht. Fortfahren?', 'delete-issue', { id: id })); return; } if (action === 'delete-document') { openModal(confirmModal('Dokument löschen', 'Das Dokument wird gelöscht. Fortfahren?', 'delete-document', { id: id })); return; } if (action === 'execute-confirm') { await executeConfirmAction(); return; } if (action === 'select-report-preview') { state.ui.reportPreviewType = type; buildDerivedData(); renderContent(); return; } if (action === 'export-report') { await exportReport(type, format || 'pdf'); return; } if (action === 'export-current-view') { await exportCurrentView(format || 'pdf'); return; } if (action === 'sync-energy') { const system = state.data.pvSystems.find(function(item) { return item.id === id; }); if (system) { await syncPvToEnergyManagement(system); } return; } if (action === 'create-default-reminders') { await createDefaultReminders(); return; } } function handleRootKeydown(event) { if (event.key === 'Escape' && state.modalEl && state.modalEl.classList.contains('show')) { closeModal(); } } function bindEvents() { if (!state.root) { return; } state.handlers.click = handleRootClick; state.handlers.submit = handleFormSubmit; state.handlers.input = debounce(handleRootInput, 180); state.handlers.change = handleRootChange; state.handlers.keydown = handleRootKeydown; state.root.addEventListener('click', state.handlers.click); state.root.addEventListener('submit', state.handlers.submit); state.root.addEventListener('input', state.handlers.input); state.root.addEventListener('change', state.handlers.change); document.addEventListener('keydown', state.handlers.keydown); } function unbindEvents() { if (!state.root) { return; } if (state.handlers.click) { state.root.removeEventListener('click', state.handlers.click); } if (state.handlers.submit) { state.root.removeEventListener('submit', state.handlers.submit); } if (state.handlers.input) { state.root.removeEventListener('input', state.handlers.input); } if (state.handlers.change) { state.root.removeEventListener('change', state.handlers.change); } if (state.handlers.keydown) { document.removeEventListener('keydown', state.handlers.keydown); } state.handlers.click = null; state.handlers.submit = null; state.handlers.input = null; state.handlers.change = null; state.handlers.keydown = null; } function seedEmptyState() { state.data.dashboard = null; state.data.roofs = []; state.data.buildings = []; state.data.issues = []; state.data.maintenance = []; state.data.inspections = []; state.data.greeningPlans = []; state.data.greeningCare = []; state.data.pvSystems = []; state.data.documents = []; state.data.reports = []; state.data.reminders = []; state.data.lookups = deepClone(DEFAULT_LOOKUPS); buildDerivedData(); } function applyInitialOptions(options) { const defaults = { apiBase: DEFAULT_API_BASE, root: null, currentUser: '', baseSpecificYieldKwhPerKwp: 980, removeStylesOnDestroy: true, autoLoad: true, energySync: null }; state.options = mergeDeep(defaults, options || {}); } async function init(options) { if (state.initializing) { return; } state.initializing = true; state.destroyed = false; applyInitialOptions(options); restoreUiState(); injectStyles(); ensureRoot(state.options); seedEmptyState(); renderContent(); bindEvents(); emitModuleEvent('before-init', { version: MODULE_VERSION }); try { if (state.options.autoLoad !== false) { await loadAllData(); renderContent(); } emitModuleEvent('after-init', { version: MODULE_VERSION }); } catch (error) { renderContent(); } finally { state.initializing = false; } } async function refresh() { if (!state.root) { return; } await loadAllData(); renderContent(); showToast('Dachdaten aktualisiert', 'success'); emitModuleEvent('refreshed', {}); } function destroy() { unbindEvents(); destroyModalInstance(); if (state.root) { state.root.innerHTML = ''; } if (state.options.removeStylesOnDestroy) { const style = document.getElementById(STYLE_ID); if (style) { style.remove(); } } state.mounted = false; state.destroyed = true; state.root = null; state.contentEl = null; state.toastEl = null; state.modalEl = null; resetTransientSelections(); emitModuleEvent('destroyed', {}); } window.BauGenioDach = { init: init, destroy: destroy, refresh: refresh }; })(); ', '' ].join('')); popup.document.close(); popup.focus(); popup.print(); return true; } function exportPdfClient(reportKey) { const titleMap = buildReportMatrix(); const report = titleMap[reportKey]; const html = renderReportPreview(reportKey); const filename = reportKey + '.pdf'; if (jsPdfAvailable()) { const JsPdfCtor = window.jspdf && window.jspdf.jsPDF ? window.jspdf.jsPDF : window.jsPDF; const doc = new JsPdfCtor({ orientation: 'portrait', unit: 'pt', format: 'a4' }); const margin = 40; const pageWidth = doc.internal.pageSize.getWidth() - margin * 2; const text = plainText(html); const lines = doc.splitTextToSize(text, pageWidth); let y = 50; doc.setFontSize(16); doc.text(report ? report.title : reportKey, margin, y); y += 24; doc.setFontSize(10); lines.forEach(function(line) { if (y > doc.internal.pageSize.getHeight() - 50) { doc.addPage(); y = 50; } doc.text(line, margin, y); y += 14; }); doc.save(filename); showToast('PDF exportiert.', 'success'); return Promise.resolve(true); } openPrintWindow(report ? report.title : 'Berichtsvorschau', html); showToast('PDF-Bibliothek fehlt. Druckdialog als Fallback geöffnet.', 'warning'); return Promise.resolve(true); } function exportViaServer(reportKey, format) { const endpoint = apiUrl(ENDPOINTS.exports); const controller = createAbortController(); const headers = { 'Content-Type': 'application/json' }; const options = { method: 'POST', headers: headers, body: JSON.stringify({ reportKey: reportKey, format: format, filters: getActiveFilters(), projectId: state.options.projectId || '' }) }; if (controller) { options.signal = controller.signal; } return fetch(endpoint, options).then(function(response) { if (!response.ok) { throw new Error('Serverexport nicht verfügbar.'); } return response.blob().then(function(blob) { const ext = format === 'excel' ? '.xlsx' : '.pdf'; saveBlob(blob, reportKey + ext); showToast('Serverexport abgeschlossen.', 'success'); return true; }); }); } function exportReport(reportKey, format) { setSaving(true); const clientFallback = function() { if (format === 'excel') { return exportExcelClient(reportKey); } return exportPdfClient(reportKey); }; const finalize = function() { setSaving(false); }; if (!state.options.demoMode) { return exportViaServer(reportKey, format) .catch(function(error) { debugLog('Serverexport fehlgeschlagen, wechsle auf Client-Fallback', error); return clientFallback(); }) .finally(finalize); } return clientFallback().finally(finalize); } function dismissToast(id) { state.toasts = state.toasts.filter(function(item) { return String(item.id) !== String(id); }); renderToastContainer(); } function handleRootClick(event) { const actionNode = event.target.closest('[data-action]'); if (!actionNode || !state.root.contains(actionNode)) { return; } const action = actionNode.getAttribute('data-action'); const id = actionNode.getAttribute('data-id'); const entityType = actionNode.getAttribute('data-entity-type'); const tab = actionNode.getAttribute('data-tab'); const page = actionNode.getAttribute('data-page'); const scope = actionNode.getAttribute('data-scope'); const reportKey = actionNode.getAttribute('data-report'); const format = actionNode.getAttribute('data-format'); if (action === 'set-tab') { setActiveTab(tab); return; } if (action === 'set-hygiene-tab') { setHygieneTab(tab); return; } if (action === 'reset-filters') { resetFilters(); return; } if (action === 'refresh-data') { refresh(); return; } if (action === 'create-entity') { openEntityModal(entityType); return; } if (action === 'edit-entity') { openEntityModal(entityType, id); return; } if (action === 'delete-entity') { removeEntity(entityType, id) .then(function(result) { if (result) { showToast('Eintrag gelöscht.', 'success'); } }) .catch(setLastError); return; } if (action === 'open-drawer') { selectEntity(entityType, id); return; } if (action === 'close-drawer') { closeDrawer(); return; } if (action === 'edit-selected-entity') { if (state.ui.selectedEntityType && state.ui.selectedEntity) { openEntityModal(state.ui.selectedEntityType, state.ui.selectedEntity); } return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'dismiss-toast') { dismissToast(id); return; } if (action === 'set-page') { setPage(scope, page); return; } if (action === 'export-report') { exportReport(reportKey, format).catch(setLastError); return; } if (action === 'preview-report') { openModal('reportPreview', { reportKey: reportKey }); return; } if (action === 'print-preview') { openPrintWindow('Berichtsvorschau', (state.ui.modalData && state.ui.modalData.html) || renderReportPreview(state.ui.modalData && state.ui.modalData.reportKey)); return; } if (action === 'generate-protocol') { const test = findById(state.data.pressureTests, id); if (test) { openModal('reportPreview', { reportKey: 'druckpruefprotokoll', html: createPressureProtocolHtml(test) }); } return; } if (action === 'generate-flush-protocol') { const log = findById(state.data.flushLogs, id); if (log) { openModal('reportPreview', { reportKey: 'hygienespuelprotokoll', html: createFlushProtocolHtml(log) }); } return; } if (action === 'create-retest') { ensurePressureRetest(id).catch(setLastError); return; } if (action === 'create-flush-log-from-plan') { const plan = findById(state.data.flushPlans, id); openModal('flushLog', { planId: plan ? plan.id : '', datum: datetimeToInputValue(new Date()), dauerMin: 10, temperaturWarm: plan ? plan.sollTemperaturWarm : 60, temperaturKalt: plan ? Math.min(toNumber(plan.sollTemperaturKalt, 25), 25) : 25 }); return; } } function handleRootChange(event) { const filterNode = event.target.closest('[data-filter]'); if (filterNode && state.root.contains(filterNode)) { const filterKey = filterNode.getAttribute('data-filter'); const value = filterNode.value; updateFilter(filterKey, value); return; } } function handleRootInput(event) { const filterNode = event.target.closest('input[data-filter="query"]'); if (filterNode && state.root.contains(filterNode)) { updateFilter('query', filterNode.value); } } function handleRootSubmit(event) { const form = event.target.closest('form[data-form]'); if (!form || !state.root.contains(form)) { return; } event.preventDefault(); const formType = form.getAttribute('data-form'); const raw = formDataToObject(form); const entityType = entityTypeFromFormType(formType); const record = serializeFormByType(formType, raw); const errors = validateRecord(entityType, record); if (errors.length) { showToast(errors[0], 'danger'); return; } setSaving(true); persistEntity(entityType, record) .then(function(saved) { closeModal(); showToast(getEntityConfig(entityType).label + ' gespeichert.', 'success'); if (entityType === 'temperature') { return maybeCreateDefectsFromTemperatureLog(saved).then(function() { return saved; }); } if (entityType === 'pressure') { return maybeCreateDefectsFromPressureTest(saved).then(function() { return saved; }); } if (entityType === 'flushLog') { const plan = findById(state.data.flushPlans, saved.planId); if (plan) { plan.letzteSpuelung = saved.datum ? saved.datum.slice(0, 10) : todayIsoDate(); plan.naechsteSpuelung = dateToInputValue(addDays(plan.letzteSpuelung, plan.intervallTage || 3)); plan.status = 'durchgefuehrt'; return persistEntity('flushPlan', plan).then(function() { return saved; }); } } return saved; }) .catch(setLastError) .finally(function() { setSaving(false); }); } function bindRootEvents() { if (!state.root) { return; } registerListener(state.root, 'click', handleRootClick); registerListener(state.root, 'change', handleRootChange); registerListener(state.root, 'input', debounce(handleRootInput, 150)); registerListener(state.root, 'submit', handleRootSubmit); registerListener(document, 'keydown', function(event) { if (event.key === 'Escape' && state.ui.modal) { closeModal(); } if (event.key === 'Escape' && state.ui.sidebarOpen) { closeDrawer(); } }); } function unbindRootEvents() { removeAllListeners(); } function hydrateFromOptions(options) { const merged = deepMerge(DEFAULT_OPTIONS, options || {}); state.options = merged; state.filters.projectId = merged.projectId || ''; state.ui.activeTab = merged.defaultTab && TAB_KEYS.indexOf(merged.defaultTab) !== -1 ? merged.defaultTab : 'dashboard'; } function refresh() { if (!state.initialized) { return Promise.resolve(false); } clearLastError(); setLoading(true); return loadAllData() .then(function() { render(); return true; }) .catch(function(error) { setLastError(error); return false; }) .finally(function() { setLoading(false); }); } function destroy() { if (!state.initialized) { return; } state.destroyed = true; clearAbortControllers(); clearTimers(); unbindRootEvents(); closeModal(); closeDrawer(); removeStyles(); if (state.root) { state.root.innerHTML = ''; } resetState(); } function init(options) { if (state.initialized) { destroy(); } resetState(); hydrateFromOptions(options || {}); injectStyles(); state.root = ensureRootElement(state.options.root); state.initialized = true; state.destroyed = false; render(); bindRootEvents(); if (state.options.autoLoad) { return refresh().then(function() { return window.BauGenioSanitaer; }); } return Promise.resolve(window.BauGenioSanitaer); } window.BauGenioSanitaer = { init: init, destroy: destroy, refresh: refresh }; })(); ' + ''; } async function exportReportPdf(reportType) { try { await apiCall('reports', 'POST', { type: reportType, format: 'pdf', filters: deepClone(state.filters), projectId: state.options.projectId }); } catch (error) { logDebug('Report-Logging fehlgeschlagen', error); } const title = getReportLabel(reportType); const html = buildReportHtml(reportType); if (hasExternalLibrary('jspdf') || (window.jspdf && window.jspdf.jsPDF)) { try { const JsPdfClass = window.jspdf && window.jspdf.jsPDF ? window.jspdf.jsPDF : window.jspdf; const doc = new JsPdfClass({ orientation: 'landscape', unit: 'pt', format: 'a4' }); const rows = getReportRows(reportType); const headers = rows.length ? Object.keys(rows[0]) : []; doc.setFontSize(18); doc.text(title, 40, 40); doc.setFontSize(10); doc.text('Stand: ' + formatDate(todayIso()), 40, 58); if (typeof doc.autoTable === 'function') { doc.autoTable({ head: [headers], body: rows.map(function(row) { return headers.map(function(header) { return safeString(row[header]); }); }), startY: 75, styles: { fontSize: 8 } }); } else { let y = 85; headers.forEach(function(header, index) { doc.text(header, 40 + (index * 70), y); }); y += 16; rows.slice(0, 30).forEach(function(row) { headers.forEach(function(header, index) { doc.text(safeString(row[header]).slice(0, 24), 40 + (index * 70), y); }); y += 14; }); } doc.save(slugify(title) + '.pdf'); notify(title + ' als PDF exportiert.', 'success'); return; } catch (error) { logDebug('jsPDF Export fehlgeschlagen, Druckvorschau wird verwendet.', error); } } const printWindow = window.open('', '_blank'); if (!printWindow) { downloadBlob(slugify(title) + '.html', 'text/html;charset=utf-8', html); notify('Popup blockiert. HTML-Datei als Fallback exportiert.', 'warning'); return; } printWindow.document.open(); printWindow.document.write(html); printWindow.document.close(); printWindow.focus(); window.setTimeout(function() { printWindow.print(); }, 250); notify(title + ' in Druckvorschau geöffnet.', 'success'); } async function exportReportXlsx(reportType) { try { await apiCall('reports', 'POST', { type: reportType, format: 'xlsx', filters: deepClone(state.filters), projectId: state.options.projectId }); } catch (error) { logDebug('Report-Logging fehlgeschlagen', error); } const title = getReportLabel(reportType); const rows = getReportRows(reportType); if (hasExternalLibrary('XLSX')) { const worksheet = window.XLSX.utils.json_to_sheet(rows); const workbook = window.XLSX.utils.book_new(); window.XLSX.utils.book_append_sheet(workbook, worksheet, slugify(title).slice(0, 31)); window.XLSX.writeFile(workbook, slugify(title) + '.xlsx'); notify(title + ' als Excel exportiert.', 'success'); return; } if (!rows.length) { notify('Keine Daten für Excel-Export vorhanden.', 'warning'); return; } const headers = Object.keys(rows[0]); const csvRows = [headers].concat(rows.map(function(row) { return headers.map(function(header) { return row[header]; }); })); const csv = buildCsv(csvRows); downloadBlob(slugify(title) + '.csv', 'text/csv;charset=utf-8', csv); notify(title + ' als CSV exportiert.', 'success'); } async function refresh() { if (!state.initialized) { return; } await loadInitialData(); render(); } function resolveContainer(container) { if (container instanceof HTMLElement) { return container; } if (typeof container === 'string' && container.trim()) { return document.querySelector(container); } return null; } async function init(options) { if (state.initialized) { destroy(); } state.options = mergeOptions({ container: null, projectId: null, projectName: '', apiBase: API_BASE, apiHeaders: {}, readOnly: false, autoLoad: true, useMockData: false, mockOnError: true, debug: false, locale: 'de-DE', currency: 'EUR' }, options || {}); state.root = resolveContainer(state.options.container); if (!state.root) { throw new Error('BauGenioFoerdertechnik: Container nicht gefunden.'); } injectStyles(); state.root.classList.add('bgz-foerdertechnik'); state.root.setAttribute('data-module-id', MODULE_ID); render(); bindEvents(); state.initialized = true; if (state.options.autoLoad) { await refresh(); } else { hydrateDerivedState(); render(); } return window.BauGenioFoerdertechnik; } function destroy() { abortActiveRequests(); clearListeners(); clearTimers(); closeModal(); if (state.root) { state.root.innerHTML = ''; state.root.classList.remove('bgz-foerdertechnik'); state.root.removeAttribute('data-module-id'); } const style = document.getElementById(STYLE_ID); if (style && style.parentNode) { style.parentNode.removeChild(style); } state.initialized = false; state.root = null; state.loading = false; state.saving = false; state.currentView = DEFAULT_VIEW; state.options = { container: null, projectId: null, projectName: '', apiBase: API_BASE, apiHeaders: {}, readOnly: false, autoLoad: true, useMockData: false, mockOnError: true, debug: false, locale: 'de-DE', currency: 'EUR' }; state.dataset = { dashboard: null, anlagen: [], montage: [], pruefungen: [], wartungen: [], stoerungen: [], kosten: [], dokumente: [], vertraege: [], lookup: { gebaeude: [], hersteller: [], firmen: [], personal: [], pruefstellen: [], zues: [] } }; resetFilters(); state.ui = { selectedAnlageId: null, selectedMontageId: null, selectedPruefungId: null, selectedWartungId: null, selectedStoerungId: null, selectedReportType: 'anlagenverzeichnis', mobileFiltersVisible: false, lastToastId: 0, detailMode: 'overview' }; state.cache = { dashboardMetrics: null, filteredAnlagen: null, lastRenderSignature: '' }; state.meta = { lastRefresh: null, lastLoadError: null, version: '4.0.0', schema: 'foerdertechnik-v1', isDirty: false }; state.modals = []; state.abortControllers = []; state.listeners = []; state.timers = []; } window.BauGenioFoerdertechnik = { init: init, destroy: destroy, refresh: refresh }; })(); ', '' ].join(''); return new Blob([html], { type: 'application/vnd.ms-excel' }); } function downloadBlob(blob, fileName) { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); link.remove(); setTimeout(function() { URL.revokeObjectURL(url); }, 500); } function exportKanalkatasterXls() { const list = getFilteredHaltungen(); const rows = list.map(function(item) { return [ item.nummer, item.bauabschnitt, getSchachtLabel(item.startschachtId), getSchachtLabel(item.endschachtId), item.laenge, item.dn, item.material, item.gefaelle, item.medium, item.status ]; }); const blob = createExcelBlob( ['Nr.', 'Bauabschnitt', 'Startschacht', 'Endschacht', 'Länge [m]', 'DN', 'Material', 'Gefälle [‰]', 'Medium', 'Status'], rows, 'Kanalkataster' ); downloadBlob(blob, 'kanalkataster.xls'); addAlert('success', 'Kanalkataster als Excel exportiert.'); } function exportKanalkatasterPdf() { const lines = []; lines.push('BauGenio Kanalbau - Kanalkataster'); lines.push('Stand: ' + formatDateTime(nowIso())); lines.push(''); getFilteredHaltungen().forEach(function(item, idx) { lines.push((idx + 1) + '. ' + item.nummer + ' | BA: ' + (item.bauabschnitt || '-') + ' | ' + getSchachtLabel(item.startschachtId) + ' -> ' + getSchachtLabel(item.endschachtId)); lines.push(' ' + formatMeters(item.laenge) + ' | ' + formatDn(item.dn) + ' | ' + (item.material || '-') + ' | ' + (item.medium || '-') + ' | Gefaelle ' + formatNumber(item.gefaelle, 2) + ' ‰'); if (item.bemerkung) { lines.push(' Bemerkung: ' + item.bemerkung); } }); const blob = createSimplePdfBlob(lines, 'Kanalkataster'); downloadBlob(blob, 'kanalkataster.pdf'); addAlert('success', 'Kanalkataster als PDF exportiert.'); } function exportGrabenberichteXls() { const list = getFilteredGrabenberichte(); const rows = list.map(function(item) { return [ formatDate(item.datum), item.bauabschnitt, getHaltungLabel(item.haltungId), item.bodenklasse, item.grundwasserstand, item.verbauTyp, item.verkehrssicherung, item.umleitung, item.aushubMeter, item.rohrVerlegtMeter, item.verfuelltMeter, item.grabenOffenMeter, item.personal, item.geraete, item.wetter ]; }); const blob = createExcelBlob( ['Datum', 'Bauabschnitt', 'Haltung', 'Bodenklasse', 'Grundwasser [m]', 'Verbau', 'Verkehr', 'Umleitung', 'Aushub [m]', 'Rohr [m]', 'Verfüllung [m]', 'Graben offen [m]', 'Personal', 'Geräte', 'Wetter'], rows, 'Grabenberichte' ); downloadBlob(blob, 'grabenberichte.xls'); addAlert('success', 'Grabenberichte als Excel exportiert.'); } function exportGrabenberichtePdf() { const lines = []; lines.push('BauGenio Kanalbau - Tagesberichte Grabenbau'); lines.push('Stand: ' + formatDateTime(nowIso())); lines.push(''); getFilteredGrabenberichte().forEach(function(item, idx) { lines.push((idx + 1) + '. ' + formatDate(item.datum) + ' | BA ' + (item.bauabschnitt || '-') + ' | Haltung ' + getHaltungLabel(item.haltungId)); lines.push(' Aushub ' + formatMeters(item.aushubMeter) + ' | Rohr ' + formatMeters(item.rohrVerlegtMeter) + ' | Verfuellung ' + formatMeters(item.verfuelltMeter) + ' | offen ' + formatMeters(item.grabenOffenMeter)); lines.push(' Bodenklasse ' + (item.bodenklasse || '-') + ' | Grundwasser ' + formatNumber(item.grundwasserstand, 2) + ' m | Verbau ' + (item.verbauTyp || '-') + ' | Verkehr ' + (item.verkehrssicherung || '-')); if (item.bemerkung) { lines.push(' Bemerkung: ' + item.bemerkung); } }); const blob = createSimplePdfBlob(lines, 'Grabenbau-Tagesberichte'); downloadBlob(blob, 'grabenberichte.pdf'); addAlert('success', 'Grabenberichte als PDF exportiert.'); } function exportSelectedPruefungPdf() { const selected = getSelectedPruefung() || state.data.dichtheitspruefungen[0]; if (!selected) { addAlert('warning', 'Keine Dichtheitsprüfung verfügbar.'); return; } const evalResult = evaluatePruefung(selected); const lines = [ 'BauGenio Kanalbau - Dichtheitspruefungsprotokoll', '', 'Protokoll: ' + (selected.protokollNr || '-'), 'Datum: ' + formatDate(selected.datum), 'Haltung: ' + getHaltungLabel(selected.haltungId), 'Norm: ' + (selected.norm || '-'), 'Methode: ' + (selected.methode || '-'), '', 'Pruefdruck Soll: ' + selected.druckSoll, 'Pruefdruck Ist: ' + selected.druckIst, 'Druckverlust zulaessig: ' + selected.druckverlustZulaessig, 'Druckverlust Ist: ' + selected.druckverlustIst, 'Haltezeit Soll [min]: ' + selected.haltezeitSoll, 'Haltezeit Ist [min]: ' + selected.haltezeitIst, 'Wasserverlust zulaessig: ' + selected.wasserverlustZulaessig, 'Wasserverlust Ist: ' + selected.wasserverlustIst, '', 'Automatische Bewertung: ' + evalResult.status, 'Freigabe: ' + (selected.freigabeStatus || '-'), 'Nachpruefung: ' + (selected.nachpruefungErforderlich ? 'ja' : 'nein'), 'Pruefer: ' + (selected.pruefer || '-'), '' ]; if (evalResult.reasons.length) { lines.push('Abweichungen: ' + evalResult.reasons.join(' | ')); lines.push(''); } if (selected.bemerkung) { lines.push('Bemerkung: ' + selected.bemerkung); } const blob = createSimplePdfBlob(lines, 'Dichtheitspruefungsprotokoll'); downloadBlob(blob, (selected.protokollNr || 'dichtheitspruefung') + '.pdf'); addAlert('success', 'Dichtheitsprüfungsprotokoll als PDF exportiert.'); } function exportSelectedTvPdf() { const selected = getSelectedInspektion() || state.data.tvInspektionen[0]; if (!selected) { addAlert('warning', 'Keine TV-Inspektion verfügbar.'); return; } const lines = [ 'BauGenio Kanalbau - TV-Inspektionsbericht', '', 'Auftragsnummer: ' + (selected.auftragsNr || '-'), 'Datum: ' + formatDate(selected.datum), 'Haltung: ' + getHaltungLabel(selected.haltungId), 'Standard: ' + (selected.standard || '-'), 'Status: ' + (selected.status || '-'), 'Zustandsklasse: ' + (selected.zustandsklasse || '-'), 'Sanierungsempfehlung: ' + (selected.sanierungsempfehlung || '-'), 'Kamera-System: ' + (selected.kameraSystem || '-'), 'Laenge untersucht [m]: ' + selected.laengeUntersucht, 'Video-Referenz: ' + (selected.videoRef || '-'), 'Screenshots: ' + (selected.screenshotRefs || '-'), '' ]; lines.push('Schadenscodierung:'); normalizeDamageRows(selected.schaeden).forEach(function(row, index) { lines.push( (index + 1) + '. Code ' + row.code + ' | Station ' + formatNumber(row.station, 2) + ' m | Umfang ' + formatNumber(row.umfang, 2) + ' % | SG ' + row.schweregrad + ' | ' + (row.bemerkung || '') ); }); if (!normalizeDamageRows(selected.schaeden).length) { lines.push('Keine Schaeden dokumentiert.'); } lines.push(''); lines.push('Beurteilung: ' + (selected.beurteilung || '-')); lines.push('Pruefer: ' + (selected.pruefer || '-')); lines.push('Freigabe: ' + (selected.freigabeStatus || '-')); const blob = createSimplePdfBlob(lines, 'TV-Inspektionsbericht'); downloadBlob(blob, (selected.auftragsNr || 'tv-inspektion') + '.pdf'); addAlert('success', 'TV-Inspektionsbericht als PDF exportiert.'); } function exportVermessungPdf() { const lines = [ 'BauGenio Kanalbau - Vermessungsprotokoll', '', 'Stand: ' + formatDateTime(nowIso()), '' ]; getFilteredSchaechte().forEach(function(item, idx) { lines.push((idx + 1) + '. Schacht ' + item.nummer + ' | BA ' + (item.bauabschnitt || '-')); lines.push(' Typ ' + (item.typ || '-') + ' | Sohle ' + formatNumber(item.sohle, 2) + ' | Deckel ' + formatNumber(item.deckel, 2) + ' | Tiefe ' + formatNumber(item.tiefe, 2)); lines.push(' X ' + item.x + ' | Y ' + item.y + ' | Material ' + (item.material || '-')); if (item.bemerkung) { lines.push(' Bemerkung: ' + item.bemerkung); } }); const blob = createSimplePdfBlob(lines, 'Vermessungsprotokoll'); downloadBlob(blob, 'vermessungsprotokoll.pdf'); addAlert('success', 'Vermessungsprotokoll als PDF exportiert.'); } function exportAllReports() { exportKanalkatasterPdf(); exportGrabenberichtePdf(); exportSelectedPruefungPdf(); exportSelectedTvPdf(); exportVermessungPdf(); } function handleClick(event) { const target = event.target.closest('[data-action]'); if (!target) { return; } const action = target.getAttribute('data-action'); const id = target.getAttribute('data-id'); const tab = target.getAttribute('data-tab'); const section = target.getAttribute('data-section'); const index = target.getAttribute('data-index'); switch (action) { case 'dismiss-alert': removeAlert(id); return; case 'switch-tab': state.activeTab = tab || 'dashboard'; render(); return; case 'refresh': refresh(); return; case 'reset-filters': state.filters = { globalSearch: '', bauabschnitt: '', haltung: '', schacht: '', medium: '', material: '', dnMin: '', dnMax: '', pruefstatus: '', zustandsklasse: '' }; render(); return; case 'toggle-compact': toggleCompactTables(); return; case 'page-prev': state.pagination[section] = Math.max(1, (state.pagination[section] || 1) - 1); render(); return; case 'page-next': state.pagination[section] = (state.pagination[section] || 1) + 1; render(); return; case 'new-haltung': state.selection.haltungId = null; render(); return; case 'duplicate-haltung': if (!getSelectedHaltung()) { addAlert('warning', 'Keine Haltung ausgewählt.'); return; } const duplicate = clone(getSelectedHaltung()); duplicate.id = ''; duplicate.nummer = duplicate.nummer ? duplicate.nummer + '-Kopie' : ''; state.selection.haltungId = null; render(); const hForm = qs('#bgz-form-haltung'); if (hForm) { Object.keys(duplicate).forEach(function(key) { if (hForm.elements[key]) { hForm.elements[key].value = duplicate[key]; } }); } return; case 'select-haltung': state.selection.haltungId = id; render(); return; case 'clear-haltung-selection': state.selection.haltungId = null; render(); return; case 'save-haltung': saveHaltungFromForm(); return; case 'delete-haltung': deleteSelectedHaltung(); return; case 'new-schacht': state.selection.schachtId = null; render(); return; case 'select-schacht': state.selection.schachtId = id; render(); return; case 'clear-schacht-selection': state.selection.schachtId = null; render(); return; case 'save-schacht': saveSchachtFromForm(); return; case 'delete-schacht': deleteSelectedSchacht(); return; case 'new-grabenbericht': state.selection.grabenberichtId = null; render(); return; case 'select-grabenbericht': state.selection.grabenberichtId = id; render(); return; case 'clear-grabenbericht-selection': state.selection.grabenberichtId = null; render(); return; case 'save-grabenbericht': saveGrabenberichtFromForm(); return; case 'delete-grabenbericht': deleteSelectedGrabenbericht(); return; case 'new-pruefung': state.selection.pruefungId = null; render(); return; case 'select-pruefung': state.selection.pruefungId = id; render(); return; case 'save-pruefung': savePruefungFromForm(); return; case 'delete-pruefung': deleteSelectedPruefung(); return; case 'evaluate-pruefung': evaluateSelectedPruefung(); return; case 'approve-pruefung': approveSelectedPruefung(); return; case 'request-retest': requestRetestForSelectedPruefung(); return; case 'new-inspektion': state.selection.inspektionId = null; state.ui.inspektionDraft = null; render(); return; case 'select-inspektion': state.selection.inspektionId = id; state.ui.inspektionDraft = null; render(); return; case 'save-inspektion': saveInspektionFromForm(); return; case 'delete-inspektion': deleteSelectedInspektion(); return; case 'add-damage-row': addDamageRow(); return; case 'remove-damage-row': removeDamageRow(index); return; case 'derive-sanierung': deriveSanierungInForm(); return; case 'create-maengel-from-inspektion': createMaengelFromSelectedInspektion(); return; case 'new-mangel': state.selection.mangelId = null; render(); return; case 'select-mangel': state.selection.mangelId = id; render(); return; case 'clear-mangel-selection': state.selection.mangelId = null; render(); return; case 'save-mangel': saveMangelFromForm(); return; case 'delete-mangel': deleteSelectedMangel(); return; case 'import-derived-maengel': importDerivedMaengel(); return; case 'export-kanalkataster-xls': exportKanalkatasterXls(); return; case 'export-kanalkataster-pdf': exportKanalkatasterPdf(); return; case 'export-grabenberichte-xls': exportGrabenberichteXls(); return; case 'export-grabenberichte-pdf': exportGrabenberichtePdf(); return; case 'export-pruefung-pdf': exportSelectedPruefungPdf(); return; case 'export-tv-pdf': exportSelectedTvPdf(); return; case 'export-vermessung-pdf': exportVermessungPdf(); return; case 'export-all-reports': exportAllReports(); return; default: return; } } const debouncedFilterRender = debounce(function() { Object.keys(state.pagination).forEach(function(key) { state.pagination[key] = 1; }); render(); }, 120); function handleInput(event) { const target = event.target; const filter = target.getAttribute('data-filter'); if (filter) { state.filters[filter] = target.value; debouncedFilterRender(); } } function handleChange(event) { const target = event.target; const filter = target.getAttribute('data-filter'); if (filter) { state.filters[filter] = target.value; debouncedFilterRender(); return; } } function bindEvents() { if (!state.root) { return; } unbindEvents(); const clickHandler = handleClick.bind(null); const inputHandler = handleInput.bind(null); const changeHandler = handleChange.bind(null); state.root.addEventListener('click', clickHandler); state.root.addEventListener('input', inputHandler); state.root.addEventListener('change', changeHandler); state.eventBindings.push({ type: 'click', handler: clickHandler }); state.eventBindings.push({ type: 'input', handler: inputHandler }); state.eventBindings.push({ type: 'change', handler: changeHandler }); } function unbindEvents() { if (!state.root) { state.eventBindings = []; return; } state.eventBindings.forEach(function(binding) { state.root.removeEventListener(binding.type, binding.handler); }); state.eventBindings = []; } async function refresh() { await loadAllData(); render(); } async function init(options) { state.options = mergeDeep(state.options, options || {}); state.root = typeof state.options.root === 'string' ? document.querySelector(state.options.root) : (state.options.root || null); if (!state.root) { throw new Error('BauGenioKanalbau: root-Element nicht gefunden.'); } injectStyles(); state.activeTab = state.options.initialTab || 'dashboard'; state.meta.initializedAt = nowIso(); state.mounted = true; render(); bindEvents(); if (state.options.autoLoad !== false) { await refresh(); } return window.BauGenioKanalbau; } function destroy() { unbindEvents(); if (state.root) { state.root.innerHTML = ''; } const style = document.getElementById(STYLE_ID); if (style) { style.remove(); } state.mounted = false; state.root = null; state.eventBindings = []; state.filters = { globalSearch: '', bauabschnitt: '', haltung: '', schacht: '', medium: '', material: '', dnMin: '', dnMax: '', pruefstatus: '', zustandsklasse: '' }; state.selection = { haltungId: null, schachtId: null, grabenberichtId: null, pruefungId: null, inspektionId: null, mangelId: null }; state.ui.inspektionDraft = null; } window.BauGenioKanalbau = { init, destroy, refresh }; })(); ' ); win.document.close(); win.focus(); window.setTimeout(function() { win.print(); }, 250); } async function exportReport(reportKey, format) { const reportNameMap = { betonierprotokoll: 'Betonierprotokoll', druckfestigkeit: 'Pruefzeugnis_Druckfestigkeit', tagebuch: 'Betoniertagebuch', mengenuebersicht: 'Mengenuebersicht' }; const baseFileName = reportNameMap[reportKey] || 'Export'; let usedFallback = false; try { const blob = await apiCall('reports/' + encodeURIComponent(reportKey), 'GET', { format: format || 'pdf', sectionId: state.filters.sectionId || '', dateFrom: state.filters.dateFrom || '', dateTo: state.filters.dateTo || '' }); if (blob instanceof Blob) { downloadBlob(blob, baseFileName + '.' + inferFileExtension(blob.type || format)); showToast('Export erstellt', 'Bericht wurde heruntergeladen.', 'success'); return; } } catch (err) { if (!state.options.allowLocalFallback) { throw err; } usedFallback = true; console.warn('Lokaler Export-Fallback', err); } if (format === 'pdf') { openPrintReport(baseFileName, renderReportHtml(reportKey)); showToast('PDF vorbereitet', usedFallback ? 'Lokaler Druckreport geöffnet.' : 'PDF-Ausgabe geöffnet.', usedFallback ? 'warning' : 'success'); return; } const csvRows = buildReportRows(reportKey); const csv = buildCsv(csvRows); downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8;' }), baseFileName + '.csv'); showToast('Excel-kompatibler Export', usedFallback ? 'CSV als Excel-Fallback heruntergeladen.' : 'CSV heruntergeladen.', usedFallback ? 'warning' : 'success'); } async function onRootClick(event) { const trigger = event.target.closest('[data-action]'); if (!trigger) { const backdrop = event.target.closest('[data-modal-backdrop]'); if (backdrop) { closeModal(); } return; } const action = trigger.getAttribute('data-action'); const id = trigger.getAttribute('data-id'); const view = trigger.getAttribute('data-view'); try { switch (action) { case 'switch-view': state.activeView = view || VIEW_KEYS.DASHBOARD; persistUiState(); render(); return; case 'refresh-module': await refresh(); return; case 'reset-filters': state.filters = { globalSearch: '', sectionId: '', bauteil: '', concreteType: '', strengthClass: '', exposureClass: '', status: '', supplierId: '', dateFrom: '', dateTo: '', testStatus: '' }; persistUiState(); render(); return; case 'close-modal': closeModal(); return; case 'open-section-modal': openSectionModal(); return; case 'edit-section': openSectionModal(sectionById(id)); return; case 'open-approval-modal': if (sectionById(id)) { openApprovalModal(sectionById(id)); } return; case 'open-order-modal': openOrderModal(); return; case 'edit-order': openOrderModal(orderById(id)); return; case 'open-delivery-modal': openDeliveryModal(); return; case 'edit-delivery': openDeliveryModal(deliveryById(id)); return; case 'open-fresh-test-modal': openFreshTestModal(); return; case 'edit-fresh-test': openFreshTestModal(freshTestById(id)); return; case 'open-cube-modal': openCubeModal(); return; case 'edit-cube': openCubeModal(cubeById(id)); return; case 'open-strength-modal': openStrengthModal(); return; case 'edit-strength': openStrengthModal(state.collections.strengthTests.find(function(item) { return String(item.id) === String(id); }) || null); return; case 'open-curing-plan-modal': openCuringPlanModal(); return; case 'edit-curing-plan': openCuringPlanModal(curingPlanById(id)); return; case 'open-curing-log-modal': openCuringLogModal(); return; case 'edit-curing-log': openCuringLogModal(state.collections.curingLogs.find(function(item) { return String(item.id) === String(id); }) || null); return; case 'open-diary-modal': openDiaryModal(); return; case 'edit-diary': openDiaryModal(diaryEntryById(id)); return; case 'open-weather-modal': openWeatherModal(); return; case 'open-resource-modal': openResourceModal(); return; case 'open-supplier-modal': openSupplierModal(); return; case 'edit-supplier': openSupplierModal(supplierById(id)); return; case 'export-report': await exportReport(trigger.getAttribute('data-report'), trigger.getAttribute('data-format') || 'pdf'); return; default: break; } if (String(action || '').indexOf('delete-') === 0) { const config = findDeleteConfig(action, id); if (!config) { return; } if (window.confirm('Datensatz wirklich löschen?')) { await destroyEntity(config); } return; } } catch (err) { handleError(err, 'Aktion ' + action); } } function onRootChange(event) { const target = event.target; if (target.matches('[data-filter-key]')) { const key = target.getAttribute('data-filter-key'); state.filters[key] = target.value; persistUiState(); render(); return; } if (target.matches('[data-filter-input="true"]')) { state.filters.globalSearch = target.value; persistUiState(); render(); return; } } function onRootInput(event) { const target = event.target; if (!target.matches('[data-filter-input="true"]')) { return; } state.filters.globalSearch = target.value; persistUiState(); render(); } async function onRootSubmit(event) { const form = event.target; if (!form || !form.matches('[data-form]')) { return; } event.preventDefault(); const formType = form.getAttribute('data-form'); const data = serializeForm(form); try { switch (formType) { case 'section': await submitSectionForm(data); return; case 'order': await submitOrderForm(data); return; case 'delivery': await submitDeliveryForm(data); return; case 'fresh-test': await submitFreshTestForm(data); return; case 'cube': await submitCubeForm(data); return; case 'strength-test': await submitStrengthTestForm(data); return; case 'curing-plan': await submitCuringPlanForm(data); return; case 'curing-log': await submitCuringLogForm(data); return; case 'diary': await submitDiaryForm(data); return; case 'weather': await submitWeatherForm(data); return; case 'resource': await submitResourceForm(data); return; case 'supplier': await submitSupplierForm(data); return; case 'approval': await submitApprovalForm(data); return; default: return; } } catch (err) { handleError(err, 'Formular ' + formType); } } function bindEvents() { if (!state.root) { return; } if (!state.ui.rootClickBound) { state.ui.rootClickBound = onRootClick.bind(this); state.ui.rootChangeBound = onRootChange.bind(this); state.ui.rootSubmitBound = onRootSubmit.bind(this); state.ui.rootInputBound = debounce(onRootInput.bind(this), 120); state.ui.windowResizeBound = debounce(function() { if (state.mounted) { render(); } }, 250); } state.root.addEventListener('click', state.ui.rootClickBound); state.root.addEventListener('change', state.ui.rootChangeBound); state.root.addEventListener('submit', state.ui.rootSubmitBound); state.root.addEventListener('input', state.ui.rootInputBound); window.addEventListener('resize', state.ui.windowResizeBound); } function unbindEvents() { if (!state.root) { return; } if (state.ui.rootClickBound) { state.root.removeEventListener('click', state.ui.rootClickBound); } if (state.ui.rootChangeBound) { state.root.removeEventListener('change', state.ui.rootChangeBound); } if (state.ui.rootSubmitBound) { state.root.removeEventListener('submit', state.ui.rootSubmitBound); } if (state.ui.rootInputBound) { state.root.removeEventListener('input', state.ui.rootInputBound); } if (state.ui.windowResizeBound) { window.removeEventListener('resize', state.ui.windowResizeBound); } } async function init(options) { if (state.mounted) { destroy(); } state.options = mergeDeep(deepClone(DEFAULT_OPTIONS), options || {}); state.csrfToken = state.options.csrfToken || findCsrfToken() || ''; restoreUiState(); ensureRoot(state.options.root); injectStyles(); bindEvents(); ensureUiHosts(); state.mounted = true; if (state.options.autoLoad !== false) { try { await refresh(); } catch (err) { handleError(err, 'Initialisierung'); ensureDemoData(); computeDerivedState(); render(); } } else { ensureDemoData(); computeDerivedState(); render(); } } function resetState() { state.activeView = VIEW_KEYS.DASHBOARD; state.loading = false; state.loadingLabel = ''; state.lastRefreshAt = null; state.warnings = []; state.notices = []; state.filters = { globalSearch: '', sectionId: '', bauteil: '', concreteType: '', strengthClass: '', exposureClass: '', status: '', supplierId: '', dateFrom: '', dateTo: '', testStatus: '' }; state.sort = { sections: { key: 'plannedDate', dir: 'asc' }, orders: { key: 'deliveryDate', dir: 'asc' }, deliveries: { key: 'arrivalTime', dir: 'desc' }, tests: { key: 'dateTime', dir: 'desc' }, curing: { key: 'startDate', dir: 'desc' }, diary: { key: 'date', dir: 'desc' }, suppliers: { key: 'name', dir: 'asc' } }; state.collections = { sections: [], orders: [], deliveries: [], freshTests: [], cubes: [], strengthTests: [], curingPlans: [], curingLogs: [], diaryEntries: [], suppliers: [], weatherLogs: [], costItems: [], approvals: [], resources: [] }; state.metrics = { orderedVolume: 0, deliveredVolume: 0, installedVolume: 0, completionPercent: 0, freshTestsPassed: 0, freshTestsOpen: 0, strength28Count: 0, strength28Passed: 0, strength28Rate: 0, supplierOnTimeRate: 0, supplierQualityRate: 0, costPlan: 0, costActual: 0, costVariance: 0, sectionsReleased: 0, sectionsTotal: 0 }; } function destroy() { unbindEvents(); closeModal(); if (state.root) { state.root.innerHTML = ''; state.root.classList.remove('bgz-beton'); } if (state.options.removeStylesOnDestroy) { const style = document.getElementById(STYLE_ID); if (style && style.parentNode) { style.parentNode.removeChild(style); } } state.ui.modalHost = null; state.ui.toastHost = null; state.ui.openModalId = null; state.mounted = false; resetState(); } window.BauGenioBeton = { init: init, destroy: destroy, refresh: refresh }; })(); '); parts.push(''); return parts.join(''); } function exportToPdf(rows, title, filename) { var JsPdf = window.jsPDF || (window.jspdf && window.jspdf.jsPDF); if (JsPdf) { var doc = new JsPdf({ orientation: rows.length && Object.keys(rows[0]).length > 6 ? 'landscape' : 'portrait' }); doc.setFontSize(16); doc.text(title, 14, 16); if (typeof doc.autoTable === 'function' && rows.length) { var headers = Object.keys(rows[0]); var body = rows.map(function(row) { return headers.map(function(header) { return String(row[header] === undefined || row[header] === null ? '' : row[header]); }); }); doc.autoTable({ head: [headers], body: body, startY: 22, styles: { fontSize: 7 }, headStyles: { fillColor: [33, 37, 41] } }); } else { var y = 26; var simpleHeaders = rows.length ? Object.keys(rows[0]) : []; var previewRows = rows.slice(0, 35); if (simpleHeaders.length) { doc.setFontSize(9); doc.text(simpleHeaders.join(' | '), 14, y); y += 6; } previewRows.forEach(function(row) { var line = simpleHeaders.map(function(header) { return String(row[header] === undefined || row[header] === null ? '' : row[header]); }).join(' | '); doc.text(line.slice(0, 180), 14, y); y += 5; if (y > 280) { doc.addPage(); y = 20; } }); } doc.save(filename); return; } var printWindow = window.open('', '_blank'); if (printWindow) { printWindow.document.open(); printWindow.document.write(buildPrintableReportHtml(title, rows)); printWindow.document.close(); printWindow.focus(); printWindow.print(); return; } exportToCsv(rows, filename.replace(/\.pdf$/i, '.csv')); showToast('PDF-Export nicht verfügbar. CSV statt PDF erzeugt.', 'warning', 5000); } function handleExport(reportKey, format) { try { var rows = getExportDataset(reportKey); var title = getReportTitle(reportKey); if (!rows.length) { showToast('Keine Daten für den Export vorhanden.', 'warning'); return; } if (format === 'xlsx') { exportToExcel(rows, getExportFilename(reportKey, 'xlsx')); showToast(title + ' als Excel exportiert.', 'success'); return; } if (format === 'pdf') { exportToPdf(rows, title, getExportFilename(reportKey, 'pdf')); showToast(title + ' als PDF exportiert.', 'success'); return; } exportToCsv(rows, getExportFilename(reportKey, 'csv')); showToast(title + ' als CSV exportiert.', 'success'); } catch (error) { handleError(error, 'handleExport:' + reportKey); showToast(error.message || 'Export fehlgeschlagen.', 'danger', 5000); } } /* ====================================================================== */ /* Rendering: Grundlayout */ /* ====================================================================== */ function render() { if (!state.root) { return; } state.root.innerHTML = renderApp(); } function renderApp() { var parts = []; parts.push('
'); parts.push(renderHeader()); parts.push(renderFilters()); parts.push(renderTabs()); parts.push('
'); parts.push(renderActiveTab()); parts.push('
'); parts.push(renderModal()); parts.push(renderToastStack()); parts.push(renderBusyOverlay()); parts.push('
'); return parts.join(''); } function renderHeader() { var parts = []; var project = state.data.project || {}; var subtitle = []; if (hasValue(project.number)) { subtitle.push(project.number); } if (hasValue(project.client)) { subtitle.push(project.client); } if (hasValue(state.api.lastSuccessAt)) { subtitle.push('Letzte Aktualisierung ' + formatDateTime(state.api.lastSuccessAt)); } parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); parts.push('

Fenster- & Türenmanagement

'); if (state.options.useDemoData || state.cache.demoSeeded && !state.api.lastSuccessAt) { parts.push('Demo'); } if (state.api.pending > 0) { parts.push('API aktiv'); } parts.push('
'); parts.push('
'); parts.push(escapeHtml(project.name || 'Projekt nicht geladen')); if (subtitle.length) { parts.push(' · ' + escapeHtml(subtitle.join(' · '))); } parts.push('
'); parts.push('
'); parts.push('
'); parts.push(''); parts.push(''); parts.push(''); parts.push(''); parts.push(''); parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); return parts.join(''); } function renderFilters() { var parts = []; parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); parts.push(''); parts.push(''); parts.push('
'); FILTER_DEFINITIONS.forEach(function(filter) { var options = filter.options || resolveOptionsSource(filter.optionsSource); parts.push('
'); parts.push(''); parts.push(''); parts.push('
'); }); parts.push('
'); parts.push(''); parts.push('
'); parts.push('
'); parts.push('
'); parts.push(renderActiveFilterChips()); parts.push('
'); parts.push('
'); parts.push('
'); return parts.join(''); } function getFilterLabel(key, value) { if (!hasValue(value)) { return ''; } switch (key) { case 'search': return 'Suche: ' + value; case 'buildingId': return 'Gebäude: ' + getLookupLabel('buildings', value); case 'floorId': return 'Geschoss: ' + getLookupLabel('floors', value); case 'roomId': return 'Raum: ' + getLookupLabel('rooms', value); case 'sectionId': return 'Bauabschnitt: ' + getLookupLabel('sections', value); case 'type': return 'Typ: ' + formatEnumLabel(value, buildGlobalTypeOptions()); case 'fireClass': return 'Brandschutz: ' + formatEnumLabel(value, FIRE_CLASS_OPTIONS); case 'soundClass': return 'Schallschutz: ' + formatEnumLabel(value, SOUND_CLASS_OPTIONS); case 'orderStatus': return 'Bestellung: ' + formatEnumLabel(value, ORDER_STATUS_OPTIONS); case 'installStatus': return 'Einbau: ' + formatEnumLabel(value, INSTALL_STATUS_OPTIONS); case 'manufacturer': return 'Hersteller: ' + value; case 'material': return 'Material: ' + formatEnumLabel(value, buildGlobalMaterialOptions()); default: return value; } } function renderActiveFilterChips() { var parts = []; var active = Object.keys(state.filters).filter(function(key) { return hasValue(state.filters[key]); }); if (!active.length) { return '
Keine aktiven Filter.
'; } parts.push('
'); active.forEach(function(key) { parts.push( '' ); }); parts.push('
'); return parts.join(''); } function renderTabs() { var parts = []; parts.push(''); return parts.join(''); } function renderActiveTab() { switch (state.ui.activeTab) { case 'dashboard': return renderDashboard(); case 'doors': return renderDoorsTab(); case 'windows': return renderWindowsTab(); case 'lock': return renderLockTab(); case 'installation': return renderInstallationTab(); case 'reports': return renderReportsTab(); default: return renderDashboard(); } } /* ====================================================================== */ /* Rendering: Dashboard */ /* ====================================================================== */ function renderKpiCard(title, value, subtitle, tone) { var cls = safeString(tone || 'secondary'); return '' + '
' + '
' + '
' + '
' + escapeHtml(title) + '
' + '
' + escapeHtml(String(value)) + '
' + '
' + escapeHtml(subtitle || '') + '
' + '
' + '
' + '
'; } function renderSummaryStrip(cards) { var parts = []; parts.push('
'); safeArray(cards).forEach(function(card) { parts.push(renderKpiCard(card.title, card.value, card.subtitle, card.tone)); }); parts.push('
'); return parts.join(''); } function renderDistributionCard(title, rows) { var parts = []; parts.push('
'); parts.push('
' + escapeHtml(title) + '
'); parts.push('
'); if (!rows.length) { parts.push('
Keine Daten vorhanden.
'); } else { parts.push('
'); rows.forEach(function(row) { parts.push('
'); parts.push('' + escapeHtml(row.label) + ''); parts.push('' + escapeHtml(String(row.count)) + ''); parts.push('
'); }); parts.push('
'); } parts.push('
'); parts.push('
'); return parts.join(''); } function rowsFromCountMap(countMap, options) { return safeArray(options).map(function(option) { return { label: option.label, count: toInteger(countMap[option.value] || 0) }; }).filter(function(row) { return row.count > 0; }); } function renderFloorProgressCard(rows) { var parts = []; parts.push('
'); parts.push('
Einbaufortschritt nach Geschoss
'); parts.push('
'); if (!rows.length) { parts.push('
Keine Geschossdaten vorhanden.
'); } else { rows.forEach(function(row) { parts.push('
'); parts.push('
'); parts.push('' + escapeHtml(row.label) + ''); parts.push('' + escapeHtml(String(row.installed)) + ' / ' + escapeHtml(String(row.total)) + ''); parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(formatPercent(row.percent)) + '
'); parts.push('
'); parts.push('
'); }); } parts.push('
'); parts.push('
'); return parts.join(''); } function renderRecentActivitiesCard() { var activities = safeArray(state.data.recentActivities).slice(0, 8); var parts = []; parts.push('
'); parts.push('
Letzte Vorgänge
'); parts.push('
'); if (!activities.length) { parts.push('
Noch keine Aktivitäten.
'); } else { parts.push('
'); activities.forEach(function(item) { parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(item.title) + '
'); parts.push('
' + escapeHtml(item.text) + '
'); parts.push('
'); parts.push('
' + escapeHtml(formatDate(item.date)) + '
'); parts.push('
'); parts.push('
'); }); parts.push('
'); } parts.push('
'); parts.push('
'); return parts.join(''); } function renderOpenIssuesCard() { var issues = getFilteredItems('issues').slice(0, 6); var parts = []; parts.push('
'); parts.push('
Offene Mängel
'); parts.push('
'); if (!issues.length) { parts.push('
Keine offenen Mängel im aktuellen Filterkontext.
'); } else { parts.push('
'); issues.forEach(function(item) { parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(item.title) + '
'); parts.push('
' + escapeHtml(getElementDisplayLabel(item.elementType, item.elementId)) + '
'); parts.push('
'); parts.push('
'); parts.push(renderStatusBadge('issueSeverity', item.severity)); parts.push('
' + escapeHtml(formatDate(item.dueDate)) + '
'); parts.push('
'); parts.push('
'); parts.push('
'); }); parts.push('
'); } parts.push('
'); parts.push('
'); return parts.join(''); } function renderCostCard(costs) { var parts = []; parts.push('
'); parts.push('
Kosten Plan vs. Ist
'); parts.push('
'); parts.push('
'); parts.push('
Plan
' + escapeHtml(formatCurrency(costs.planned)) + '
'); parts.push('
Ist
' + escapeHtml(formatCurrency(costs.actual)) + '
'); parts.push('
Abweichung
' + escapeHtml(formatCurrency(costs.delta)) + ' (' + escapeHtml(formatPercent(costs.deltaPercent)) + ')
'); parts.push('
'); parts.push('
'); parts.push('
'); return parts.join(''); } function renderDashboard() { var dashboard = state.data.dashboard || {}; var cards = [ { title: 'Türen gesamt', value: dashboard.totals ? dashboard.totals.doors : 0, subtitle: 'Alle Tür-Elemente', tone: 'primary' }, { title: 'Fenster gesamt', value: dashboard.totals ? dashboard.totals.windows : 0, subtitle: 'Alle Fenster-Elemente', tone: 'primary' }, { title: 'Einbaufortschritt', value: formatPercent(dashboard.installProgressPercent || 0), subtitle: 'Über alle Elemente', tone: 'success' }, { title: 'Offene Abnahmen', value: dashboard.openAcceptances || 0, subtitle: 'Nicht abgeschlossen', tone: 'warning' }, { title: 'Schließanlage', value: formatPercent(dashboard.lockMetrics ? dashboard.lockMetrics.coveragePercent : 0), subtitle: 'Konfigurationsgrad', tone: 'info' }, { title: 'Kostenabweichung', value: dashboard.costs ? formatCurrency(dashboard.costs.delta) : formatCurrency(0), subtitle: 'Ist vs. Plan', tone: dashboard.costs && dashboard.costs.delta > 0 ? 'danger' : 'success' } ]; var parts = []; if ((dashboard.totals && dashboard.totals.elements === 0) || (!state.data.doors.length && !state.data.windows.length)) { return renderEmptyState( 'Noch keine Elemente vorhanden', 'Lege zuerst Türen oder Fenster an oder lade Daten aus der API.' ); } parts.push(renderSummaryStrip(cards)); parts.push('
'); parts.push('
' + renderDistributionCard('Türtypen', rowsFromCountMap(dashboard.byDoorType || {}, DOOR_TYPE_OPTIONS)) + '
'); parts.push('
' + renderDistributionCard('Fenstertypen', rowsFromCountMap(dashboard.byWindowType || {}, WINDOW_TYPE_OPTIONS)) + '
'); parts.push('
' + renderDistributionCard('Bestellstatus', rowsFromCountMap(dashboard.orderStatus || {}, ORDER_STATUS_OPTIONS)) + '
'); parts.push('
'); parts.push('
'); parts.push('
' + renderDistributionCard('Einbaustatus', rowsFromCountMap(dashboard.installStatus || {}, INSTALL_STATUS_OPTIONS)) + '
'); parts.push('
' + renderCostCard(dashboard.costs || { planned: 0, actual: 0, delta: 0, deltaPercent: 0 }) + '
'); parts.push('
' + renderOpenIssuesCard() + '
'); parts.push('
'); parts.push('
'); parts.push('
' + renderFloorProgressCard(dashboard.floorProgress || []) + '
'); parts.push('
' + renderRecentActivitiesCard() + '
'); parts.push('
'); return parts.join(''); } /* ====================================================================== */ /* Rendering: Tabellen */ /* ====================================================================== */ function renderEmptyState(title, text) { return '' + '
' + '
' + '
' + '
' + '

' + escapeHtml(title) + '

' + '

' + escapeHtml(text) + '

' + '
' + '
' + '
'; } function renderTableToolbarButtons(tableKey) { switch (tableKey) { case 'doors': return '' + '' + '' + ''; case 'windows': return '' + '' + '' + ''; case 'cylinders': return '' + ''; case 'keys': return '' + ''; case 'keyLogs': return '' + ''; case 'routes': return '' + ''; case 'protocols': return '' + ''; case 'acceptances': return '' + ''; case 'issues': return '' + ''; case 'orders': return '' + '' + ''; default: return ''; } } function renderSortIcon(tableKey, key) { var current = state.ui.sort[tableKey]; if (!current || safeString(current.key) !== safeString(key)) { return '↕'; } return current.dir === 'asc' ? '↑' : '↓'; } function renderRowActions(entity, row) { if (!entity) { return ''; } var parts = []; parts.push('
'); if (entity === 'door' || entity === 'window') { parts.push(''); } parts.push(''); parts.push(''); parts.push('
'); return parts.join(''); } function renderDataTable(tableKey, rows) { var schema = TABLE_SCHEMAS[tableKey]; var parts = []; parts.push('
'); parts.push(''); parts.push(''); parts.push(''); schema.columns.forEach(function(column) { parts.push(''); }); if (schema.actions !== false) { parts.push(''); } parts.push(''); parts.push(''); parts.push(''); if (!rows.length) { parts.push(''); parts.push(''); parts.push(''); } else { rows.forEach(function(row) { parts.push(''); schema.columns.forEach(function(column) { var value = ''; parts.push(''); }); if (schema.actions !== false) { parts.push(''); } parts.push(''); }); } parts.push(''); parts.push('
'); if (column.sortable) { parts.push(''); } else { parts.push(escapeHtml(column.label)); } parts.push('Aktionen
'); parts.push(escapeHtml(schema.emptyText || 'Keine Daten.')); parts.push('
'); if (typeof column.formatter === 'function') { value = column.formatter(row); if (column.raw) { parts.push(value); } else { parts.push(escapeHtml(value)); } } else { value = hasValue(row[column.key]) ? row[column.key] : '—'; parts.push(escapeHtml(String(value))); } parts.push(''); parts.push(renderRowActions(schema.entity, row)); parts.push('
'); parts.push('
'); return parts.join(''); } function renderPagination(tableKey, pageData) { if (!pageData || pageData.totalPages <= 1) { return '
' + escapeHtml(String(pageData.total || 0)) + ' Einträge
'; } var parts = []; var start = Math.max(1, pageData.page - 2); var end = Math.min(pageData.totalPages, pageData.page + 2); parts.push('
'); parts.push('
' + escapeHtml(String(pageData.total)) + ' Einträge · Seite ' + escapeHtml(String(pageData.page)) + ' von ' + escapeHtml(String(pageData.totalPages)) + '
'); parts.push('
'); parts.push(''); for (var i = start; i <= end; i += 1) { parts.push(''); } parts.push(''); parts.push('
'); parts.push('
'); return parts.join(''); } function renderTableCard(tableKey, title, introHtml) { var filtered = getFilteredItems(tableKey); var sorted = getSortedItems(tableKey, filtered); var paged = getPagedItems(tableKey, sorted); var parts = []; parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); parts.push('' + escapeHtml(title) + ''); parts.push('
' + escapeHtml(String(filtered.length)) + ' Treffer im aktuellen Filterkontext
'); parts.push('
'); parts.push('
'); parts.push(renderTableToolbarButtons(tableKey)); parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); if (introHtml) { parts.push(introHtml); } parts.push(renderDataTable(tableKey, paged.items)); parts.push(renderPagination(tableKey, paged)); parts.push('
'); parts.push('
'); return parts.join(''); } /* ====================================================================== */ /* Rendering: Türliste */ /* ====================================================================== */ function renderDoorHardwareSummary(doors) { var parts = []; var total = doors.length || 1; var coverage = [ { label: 'Drücker definiert', count: countWhere(doors, function(item) { return hasValue(item.druecker); }) }, { label: 'Schloss definiert', count: countWhere(doors, function(item) { return hasValue(item.lockType); }) }, { label: 'Band definiert', count: countWhere(doors, function(item) { return hasValue(item.bandType); }) }, { label: 'Türschließer definiert', count: countWhere(doors, function(item) { return safeString(item.doorCloser) !== 'nein'; }) }, { label: 'Feststellanlage definiert', count: countWhere(doors, function(item) { return safeString(item.holdOpen) !== 'nein'; }) } ]; parts.push('
'); parts.push('
Beschlagplanung
'); parts.push('
'); parts.push('
'); coverage.forEach(function(row) { var percent = total ? (row.count / total) * 100 : 0; parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(row.label) + '
'); parts.push('
' + escapeHtml(String(row.count)) + ' / ' + escapeHtml(String(doors.length)) + '
'); parts.push('
'); parts.push('
'); parts.push('
'); }); parts.push('
'); parts.push('
'); parts.push('
'); return parts.join(''); } function renderDoorsTab() { var doors = getFilteredItems('doors'); var cards = [ { title: 'Türen', value: doors.length, subtitle: 'Gefilterte Türliste', tone: 'primary' }, { title: 'Brandschutztüren', value: countWhere(doors, function(item) { return safeString(item.fireClass) !== 'ohne'; }), subtitle: 'T30 / T60 / T90', tone: 'danger' }, { title: 'Bestellt / offen', value: countWhere(doors, function(item) { return ['geplant', 'bestellt', 'teilgeliefert'].indexOf(safeString(item.orderStatus)) !== -1; }), subtitle: 'Noch nicht vollständig geliefert', tone: 'warning' }, { title: 'Abnahme offen', value: countWhere(doors, function(item) { return safeString(item.installStatus) === 'abnahme_offen'; }), subtitle: 'Einbau abgeschlossen, Prüfung offen', tone: 'info' }, { title: 'Kosten Ist', value: formatCurrency(sumBy(doors, 'actualCost')), subtitle: 'Gefilterte Auswahl', tone: 'success' }, { title: 'Schließanlage relevant', value: countWhere(doors, function(item) { return safeString(item.requiresLockPlan) === 'ja'; }), subtitle: 'Mit Zylinder-/Schlüsselbezug', tone: 'secondary' } ]; var parts = []; parts.push(renderSummaryStrip(cards)); parts.push(renderDoorHardwareSummary(doors)); parts.push(renderTableCard('doors', 'Türliste / Türmatrix')); return parts.join(''); } /* ====================================================================== */ /* Rendering: Fensterliste */ /* ====================================================================== */ function renderWindowPerformanceSummary(windows) { var parts = []; var total = windows.length || 1; var avgUw = windows.length ? (sumBy(windows, 'uw') / windows.length) : 0; var avgUg = windows.length ? (sumBy(windows, 'ug') / windows.length) : 0; var avgG = windows.length ? (sumBy(windows, 'gValue') / windows.length) : 0; var cards = [ { label: 'Ø Uw', value: formatNumber(avgUw, 2) }, { label: 'Ø Ug', value: formatNumber(avgUg, 2) }, { label: 'Ø g-Wert', value: formatNumber(avgG, 2) }, { label: '3-fach / Sonderglas', value: String(countWhere(windows, function(item) { return ['dreifach', 'sonnenschutz', 'schallschutz', 'sicherheitsglas'].indexOf(safeString(item.glazingType)) !== -1; })) + ' / ' + String(windows.length) }, { label: 'Lüftungselemente', value: String(countWhere(windows, function(item) { return safeString(item.ventilationElement) !== 'nein'; })) + ' / ' + String(total) }, { label: 'Absturzsicherung', value: String(countWhere(windows, function(item) { return safeString(item.fallProtection) !== 'nein'; })) + ' / ' + String(total) } ]; parts.push('
'); parts.push('
Verglasung, Energie & Sicherheit
'); parts.push('
'); parts.push('
'); cards.forEach(function(card) { parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(card.label) + '
'); parts.push('
' + escapeHtml(card.value) + '
'); parts.push('
'); parts.push('
'); }); parts.push('
'); parts.push('
'); parts.push('
'); return parts.join(''); } function renderWindowsTab() { var windows = getFilteredItems('windows'); var cards = [ { title: 'Fenster', value: windows.length, subtitle: 'Gefilterte Fensterliste', tone: 'primary' }, { title: '3-fach / Sonderglas', value: countWhere(windows, function(item) { return ['dreifach', 'sonnenschutz', 'schallschutz', 'sicherheitsglas'].indexOf(safeString(item.glazingType)) !== -1; }), subtitle: 'Energetisch oder funktional', tone: 'info' }, { title: 'Abnahme offen', value: countWhere(windows, function(item) { return safeString(item.installStatus) === 'abnahme_offen'; }), subtitle: 'Einbau fertig, Prüfung ausstehend', tone: 'warning' }, { title: 'Schallschutzfenster', value: countWhere(windows, function(item) { return ['sk3', 'sk4', 'sk5', 'sk6'].indexOf(safeString(item.soundClass)) !== -1; }), subtitle: 'Ab SK3', tone: 'secondary' }, { title: 'Ø Uw', value: formatNumber(windows.length ? sumBy(windows, 'uw') / windows.length : 0, 2), subtitle: 'Gefilterte Auswahl', tone: 'success' }, { title: 'Kosten Ist', value: formatCurrency(sumBy(windows, 'actualCost')), subtitle: 'Gefilterte Auswahl', tone: 'success' } ]; var parts = []; parts.push(renderSummaryStrip(cards)); parts.push(renderWindowPerformanceSummary(windows)); parts.push(renderTableCard('windows', 'Fensterliste')); return parts.join(''); } /* ====================================================================== */ /* Rendering: Schließanlage */ /* ====================================================================== */ function renderLockCoverageSummary() { var metrics = calculateLockMetrics(); var parts = []; parts.push('
'); parts.push('
Schließanlagen-Deckung
'); parts.push('
'); parts.push('
'); parts.push('
Relevante Türen
' + escapeHtml(String(metrics.relevantDoorCount)) + '
'); parts.push('
Konfiguriert
' + escapeHtml(String(metrics.configuredDoorCount)) + '
'); parts.push('
Offen
' + escapeHtml(String(metrics.uncoveredDoorCount)) + '
'); parts.push('
Deckungsgrad
' + escapeHtml(formatPercent(metrics.coveragePercent)) + '
'); parts.push('
'); if (metrics.uncoveredDoors.length) { parts.push('
Türen ohne Zylinderzuordnung
'); parts.push('
'); parts.push(''); parts.push(''); parts.push(''); metrics.uncoveredDoors.slice(0, 8).forEach(function(item) { parts.push(''); parts.push(''); parts.push(''); parts.push(''); parts.push(''); }); parts.push(''); parts.push('
TürOrtTyp
' + escapeHtml(item.code + ' — ' + item.name) + '' + escapeHtml(formatLocation(item)) + '' + escapeHtml(formatEnumLabel(item.type, DOOR_TYPE_OPTIONS)) + '
'); parts.push('
'); } else { parts.push('
Alle schließanlagenrelevanten Türen sind einem Zylinder zugeordnet.
'); } parts.push('
'); parts.push('
'); return parts.join(''); } function renderLockTab() { var metrics = calculateLockMetrics(); var cards = [ { title: 'Zylinder', value: metrics.cylinderCount, subtitle: 'Gesamt', tone: 'primary' }, { title: 'Schlüssel / Medien', value: metrics.keyCount, subtitle: 'Gesamt', tone: 'primary' }, { title: 'Ausgegeben', value: metrics.issuedKeyCount, subtitle: 'Aktive Medien', tone: 'warning' }, { title: 'Verloren', value: metrics.lostKeyCount, subtitle: 'Risikofall', tone: 'danger' }, { title: 'Elektronische Systeme', value: metrics.electronicSystemCount, subtitle: 'Transponder / Karte / App', tone: 'info' }, { title: 'Deckungsgrad', value: formatPercent(metrics.coveragePercent), subtitle: 'Relevante Türen', tone: 'success' } ]; var parts = []; parts.push(renderSummaryStrip(cards)); parts.push(renderLockCoverageSummary()); parts.push(renderTableCard('cylinders', 'Zylinder & Schließplan')); parts.push(renderTableCard('keys', 'Schlüsselverwaltung')); parts.push(renderTableCard('keyLogs', 'Schlüsselbewegungen')); return parts.join(''); } /* ====================================================================== */ /* Rendering: Einbau */ /* ====================================================================== */ function renderInstallationOverview() { var routes = getFilteredItems('routes'); var protocols = getFilteredItems('protocols'); var acceptances = getFilteredItems('acceptances'); var issues = getFilteredItems('issues'); var cards = [ { title: 'Routen', value: routes.length, subtitle: 'Gefilterte Einbaurouten', tone: 'primary' }, { title: 'In Arbeit', value: countWhere(routes, function(item) { return safeString(item.status) === 'in_arbeit'; }), subtitle: 'Aktive Montage', tone: 'warning' }, { title: 'Protokolle offen', value: countWhere(protocols, function(item) { return safeString(item.status) !== 'abgeschlossen'; }), subtitle: 'Noch nicht final', tone: 'info' }, { title: 'Abnahmen offen', value: countWhere(acceptances, function(item) { return safeString(item.status) !== 'abgenommen'; }), subtitle: 'Mit Restpunkten oder offen', tone: 'warning' }, { title: 'Mängel offen', value: countWhere(issues, function(item) { return ['offen', 'in_bearbeitung', 'nachpruefung'].indexOf(safeString(item.status)) !== -1; }), subtitle: 'Im Bauablauf', tone: 'danger' }, { title: 'Fortschritt', value: formatPercent(calculateInstallPercent()), subtitle: 'Über alle Elemente', tone: 'success' } ]; return renderSummaryStrip(cards); } function renderInstallationTab() { var parts = []; parts.push(renderInstallationOverview()); parts.push('
'); parts.push('
' + renderFloorProgressCard(buildInstallProgressByFloor()) + '
'); parts.push('
' + renderOpenIssuesCard() + '
'); parts.push('
'); parts.push(renderTableCard('routes', 'Einbaurouten')); parts.push(renderTableCard('protocols', 'Einbauprotokolle')); parts.push(renderTableCard('acceptances', 'Abnahmen')); parts.push(renderTableCard('issues', 'Mängel')); return parts.join(''); } /* ====================================================================== */ /* Rendering: Berichte & Export */ /* ====================================================================== */ function renderReportActionCard(reportKey, title, text) { var parts = []; parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(title) + '
'); parts.push('
' + escapeHtml(text) + '
'); parts.push('
'); parts.push(''); parts.push(''); parts.push('
'); parts.push('
'); parts.push('
'); parts.push('
'); return parts.join(''); } function renderReportsNarrative() { var dashboard = buildDashboardModel(); var lockMetrics = dashboard.lockMetrics || {}; var costs = dashboard.costs || {}; var text = 'Der aktuelle Projektstand zeigt ' + String(dashboard.totals.elements || 0) + ' Elemente, einen Einbaufortschritt von ' + formatPercent(dashboard.installProgressPercent || 0) + ', ' + String(dashboard.openAcceptances || 0) + ' offene Abnahmen, ' + String(dashboard.openIssues || 0) + ' offene Mängel und einen Schließanlagen-Deckungsgrad von ' + formatPercent(lockMetrics.coveragePercent || 0) + '. Die Kostenabweichung liegt bei ' + formatCurrency(costs.delta || 0) + '.'; return '' + '
' + '
' + 'Management-Zusammenfassung' + '
' + escapeHtml(text) + '
' + '
' + '
'; } function renderReportsTab() { var parts = []; parts.push(renderReportsNarrative()); parts.push('
'); parts.push(renderReportActionCard('doors', 'Türliste', 'Vollständige Türmatrix mit Status, Beschlägen, Kosten und Terminen.')); parts.push(renderReportActionCard('windows', 'Fensterliste', 'Fensterübersicht mit Verglasung, Uw/Ug/g-Werten und Einbaustatus.')); parts.push(renderReportActionCard('lockplan', 'Schließplan-Übersicht', 'Zylinder, Hierarchien, Medien und Systemstatus.')); parts.push(renderReportActionCard('progress', 'Einbaufortschrittsbericht', 'Geschossbezogene Fortschritte und Route-Status.')); parts.push(renderReportActionCard('orders', 'Bestellübersicht', 'Alle offenen Bestellungen für Türen und Fenster.')); parts.push('
'); parts.push(renderTableCard('orders', 'Offene Bestellungen')); return parts.join(''); } /* ====================================================================== */ /* Rendering: Modal */ /* ====================================================================== */ function renderModalErrors() { var errors = safeArray(state.ui.modalErrors); if (!errors.length) { return ''; } var parts = []; parts.push('
'); parts.push('
Bitte korrigieren:
'); parts.push(''); parts.push('
'); return parts.join(''); } function renderSelectOptions(options, selectedValue) { return safeArray(options).map(function(option) { var selected = safeString(selectedValue) === safeString(option.value) ? ' selected' : ''; return ''; }).join(''); } function renderChecklistField(field, value) { var parts = []; var items = safeArray(field.checklistItems); var current = normalizeChecklist(value); parts.push('
'); parts.push(''); parts.push('
'); items.forEach(function(item) { var name = field.name + '__' + item.key; var checked = current[item.key] ? ' checked' : ''; parts.push(''); }); parts.push('
'); parts.push('
'); return parts.join(''); } function renderField(field, entity, record) { if (field.type === 'section') { return '' + '
' + '
' + escapeHtml(field.title) + '
' + '
'; } if (field.type === 'hidden') { return ''; } if (field.type === 'checklist') { return renderChecklistField(field, record[field.name]); } var value = record[field.name]; var inputValue = ''; if (field.type === 'textarea' && field.asArray) { inputValue = joinLines(value); } else if (field.type === 'currency' || field.type === 'number') { inputValue = hasValue(value) ? String(value) : ''; } else { inputValue = hasValue(value) ? String(value) : ''; } var parts = []; parts.push('
'); parts.push(''); if (field.type === 'select') { parts.push(''); } else if (field.type === 'textarea') { parts.push(''); } else { var inputType = field.type === 'currency' || field.type === 'number' ? 'number' : (field.type === 'date' ? 'date' : 'text'); if (field.unit) { parts.push('
'); } parts.push(''); if (field.unit) { parts.push('' + escapeHtml(field.unit) + ''); parts.push('
'); } } if (field.help) { parts.push('
' + escapeHtml(field.help) + '
'); } parts.push('
'); return parts.join(''); } function renderModal() { if (!state.ui.modal.open || !state.ui.modal.entity) { return ''; } var entity = state.ui.modal.entity; var record = getModalRecord(); var schema = ENTITY_SCHEMAS[entity] || []; var meta = ENTITY_META[entity]; var parts = []; var title = (state.ui.modal.mode === 'edit' ? 'Bearbeiten: ' : 'Neu anlegen: ') + (meta ? meta.label : entity); parts.push('
'); parts.push(''); return parts.join(''); } /* ====================================================================== */ /* Rendering: Toasts & Overlay */ /* ====================================================================== */ function renderToastStack() { if (!state.ui.toasts.length) { return ''; } var parts = []; parts.push('
'); state.ui.toasts.forEach(function(toast) { parts.push('
'); parts.push('
'); parts.push('
' + escapeHtml(toast.message) + '
'); parts.push(''); parts.push('
'); parts.push('
'); }); parts.push('
'); return parts.join(''); } function renderBusyOverlay() { if (!hasBusyOperations()) { return ''; } return '' + '
' + '
' + '' + '
Verarbeite Daten …
' + '
' + '
'; } /* ====================================================================== */ /* Events */ /* ====================================================================== */ function handleClick(event) { var actionEl = event.target.closest('[data-action]'); if (!actionEl) { return; } var action = actionEl.getAttribute('data-action'); if (action === 'switch-tab') { changeTab(actionEl.getAttribute('data-tab')); return; } if (action === 'refresh') { refresh(); return; } if (action === 'reset-filters') { resetFilters(); return; } if (action === 'clear-filter') { setFilter(actionEl.getAttribute('data-filter-key'), ''); return; } if (action === 'open-create') { openEntityModal(actionEl.getAttribute('data-entity'), 'create'); return; } if (action === 'edit-record') { openEntityModal(actionEl.getAttribute('data-entity'), 'edit', actionEl.getAttribute('data-id')); return; } if (action === 'delete-record') { deleteRecord(actionEl.getAttribute('data-entity'), actionEl.getAttribute('data-id')); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'toggle-sort') { toggleSort(actionEl.getAttribute('data-table'), actionEl.getAttribute('data-key')); return; } if (action === 'paginate') { changePage(actionEl.getAttribute('data-table'), actionEl.getAttribute('data-page')); return; } if (action === 'open-issue-for-element') { var type = actionEl.getAttribute('data-element-type'); openEntityModal('issue', 'create', '', { elementType: type === 'window' ? 'window' : 'door', elementId: actionEl.getAttribute('data-element-id'), status: 'offen', severity: 'mittel' }); return; } if (action === 'export-report') { handleExport(actionEl.getAttribute('data-report'), actionEl.getAttribute('data-format')); return; } if (action === 'dismiss-toast') { removeToast(actionEl.getAttribute('data-toast-id')); } } function handleInput(event) { var target = event.target; if (target && target.matches('[data-filter-key="search"]')) { if (state.timers.search) { window.clearTimeout(state.timers.search); } state.timers.search = window.setTimeout(function() { setFilter('search', target.value); }, 180); } } function handleChange(event) { var target = event.target; if (target && target.matches('[data-filter-key]') && target.getAttribute('data-filter-key') !== 'search') { setFilter(target.getAttribute('data-filter-key'), target.value); } } function handleSubmit(event) { var form = event.target; if (!form || !form.matches('form[data-form-entity]')) { return; } event.preventDefault(); var entity = form.getAttribute('data-form-entity'); var record = readRecordFromForm(form, entity); saveRecord(entity, record); } function handleKeydown(event) { if (event.key === 'Escape' && state.ui.modal.open) { closeModal(); } } function bindEvents() { addDomListener(state.root, 'click', handleClick); addDomListener(state.root, 'input', handleInput); addDomListener(state.root, 'change', handleChange); addDomListener(state.root, 'submit', handleSubmit); addDomListener(document, 'keydown', handleKeydown); } /* ====================================================================== */ /* Lifecycle */ /* ====================================================================== */ function init(options) { if (state.initialized) { destroy(); } resetState(); state.options = deepMerge(DEFAULT_OPTIONS, options || {}); state.root = resolveRoot(state.options.container); if (!state.root) { throw new Error('Container für BauGenioFensterTueren nicht gefunden.'); } state.originalHtml = state.root.innerHTML; state.ui.pagination = createDefaultPaginationState(state.options.pageSize || DEFAULT_OPTIONS.pageSize); state.initialized = true; injectStyles(); restoreUiState(); bindEvents(); setupAutoRefresh(); render(); return loadAllData() .then(function() { if (typeof state.options.onReady === 'function') { try { state.options.onReady({ module: MODULE_NAME, snapshot: getPublicSnapshot() }); } catch (error) { logDebug('onReady callback error', error); } } return window.BauGenioFensterTueren; }); } function destroy() { var root = state.root; var originalHtml = state.originalHtml; removeAllListeners(); clearAllTimers(); if (root) { root.innerHTML = originalHtml || ''; } removeStyles(); resetState(); } function refresh() { if (!state.initialized) { return Promise.resolve(); } return loadAllData(); } /* ====================================================================== */ /* Styles */ /* ====================================================================== */ function injectStyles() { if (document.getElementById(STYLE_ID)) { return; } var style = document.createElement('style'); style.id = STYLE_ID; style.type = 'text/css'; style.textContent = [ '.bgz-ft-root{position:relative;color:var(--bs-body-color,#212529);}', '.bgz-ft-title{font-size:1.5rem;font-weight:700;letter-spacing:-0.01em;}', '.bgz-ft-title-wrap .small{line-height:1.4;}', '.bgz-ft-filter-card .form-label{font-size:.8rem;font-weight:600;color:#6c757d;}', '.bgz-ft-chip{border-radius:999px;}', '.bgz-ft-tabs{gap:.5rem;flex-wrap:wrap;}', '.bgz-ft-tabs .nav-link{border-radius:999px;border:1px solid rgba(0,0,0,.08);background:#fff;color:var(--bs-body-color,#212529);}', '.bgz-ft-tabs .nav-link.active{box-shadow:0 .125rem .25rem rgba(0,0,0,.08);}', '.bgz-ft-kpi{overflow:hidden;}', '.bgz-ft-kpi .card-body{position:relative;}', '.bgz-ft-kpi-primary{border-left:4px solid var(--bs-primary,#0d6efd);}', '.bgz-ft-kpi-success{border-left:4px solid var(--bs-success,#198754);}', '.bgz-ft-kpi-warning{border-left:4px solid var(--bs-warning,#ffc107);}', '.bgz-ft-kpi-danger{border-left:4px solid var(--bs-danger,#dc3545);}', '.bgz-ft-kpi-info{border-left:4px solid var(--bs-info,#0dcaf0);}', '.bgz-ft-kpi-secondary{border-left:4px solid var(--bs-secondary,#6c757d);}', '.bgz-ft-kpi-value{font-size:1.75rem;font-weight:700;line-height:1.1;}', '.bgz-ft-stat-box{background:#f8f9fa;border:1px solid rgba(0,0,0,.06);border-radius:.75rem;padding:.85rem 1rem;}', '.bgz-ft-badge{font-weight:600;}', '.bgz-ft-table thead th{font-size:.78rem;text-transform:none;color:#6c757d;white-space:nowrap;}', '.bgz-ft-table tbody td{font-size:.92rem;}', '.bgz-ft-sort{color:inherit;font-weight:600;}', '.bgz-ft-sort:hover{color:var(--bs-primary,#0d6efd);}', '.bgz-ft-content .card{border-radius:1rem;}', '.bgz-ft-form-section{font-size:.95rem;font-weight:700;padding:.5rem .75rem;border-radius:.75rem;background:#f8f9fa;border:1px solid rgba(0,0,0,.06);margin-top:.25rem;}', '.bgz-ft-checklist{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.75rem;padding:.75rem;border:1px solid rgba(0,0,0,.06);border-radius:.75rem;background:#f8f9fa;}', '.bgz-ft-checkitem{display:flex;gap:.6rem;align-items:flex-start;background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:.75rem;padding:.65rem .75rem;margin:0;}', '.bgz-ft-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1050;}', '.bgz-ft-modal{z-index:1055;}', '.bgz-ft-toast-stack{position:fixed;top:1rem;right:1rem;z-index:1080;display:flex;flex-direction:column;gap:.75rem;max-width:min(420px,calc(100vw - 2rem));}', '.bgz-ft-toast{margin:0;border-radius:1rem;}', '.bgz-ft-busy-overlay{position:absolute;inset:0;background:rgba(255,255,255,.72);backdrop-filter:blur(1px);display:flex;align-items:center;justify-content:center;z-index:1070;}', '.bgz-ft-busy-box{background:#fff;border-radius:1rem;padding:1.5rem 2rem;text-align:center;border:1px solid rgba(0,0,0,.08);}', '.bgz-ft-empty-state{max-width:36rem;margin:0 auto;}', '.bgz-ft-empty-icon{font-size:3rem;line-height:1;color:#adb5bd;}', '.bgz-ft-root .progress{height:.65rem;border-radius:999px;background:#e9ecef;}', '.bgz-ft-root .progress-bar{border-radius:999px;}', '.bgz-ft-root .btn-outline-secondary,.bgz-ft-root .btn-outline-primary,.bgz-ft-root .btn-primary{border-radius:.75rem;}', '.bgz-ft-root .form-control,.bgz-ft-root .form-select,.bgz-ft-root .input-group-text{border-radius:.75rem;}', '.bgz-ft-root .input-group .form-control{border-top-right-radius:0;border-bottom-right-radius:0;}', '.bgz-ft-root .input-group .input-group-text{border-top-left-radius:0;border-bottom-left-radius:0;}', '.bgz-ft-root .modal-content{border-radius:1rem;}', '.bgz-ft-root .card-header{border-bottom:1px solid rgba(0,0,0,.04);border-top-left-radius:1rem!important;border-top-right-radius:1rem!important;}', '.bgz-ft-root .table-responsive{border-radius:.75rem;}', '.bgz-ft-root .table > :not(caption) > * > *{padding:.75rem .75rem;}', '.bgz-ft-root .list-group-item{border-color:rgba(0,0,0,.05);}', '.bgz-ft-root .alert{border-radius:1rem;}', '@media (max-width: 991.98px){', ' .bgz-ft-title{font-size:1.3rem;}', ' .bgz-ft-kpi-value{font-size:1.45rem;}', ' .bgz-ft-toast-stack{left:1rem;right:1rem;max-width:none;}', '}', '@media (max-width: 575.98px){', ' .bgz-ft-checklist{grid-template-columns:1fr;}', ' .bgz-ft-root .table > :not(caption) > * > *{padding:.6rem .5rem;}', '}' ].join('\n'); document.head.appendChild(style); } function removeStyles() { var style = document.getElementById(STYLE_ID); if (style && style.parentNode) { style.parentNode.removeChild(style); } } /* ====================================================================== */ /* Export */ /* ====================================================================== */ window.BauGenioFensterTueren = { init: init, destroy: destroy, refresh: refresh }; })(); ' ].join('')); printWindow.document.close(); printWindow.focus(); printWindow.print(); } function exportRowsToExcel(title, columns, rows) { if (window.XLSX && isFunction(window.XLSX.utils.book_new)) { const workbook = window.XLSX.utils.book_new(); const worksheet = window.XLSX.utils.aoa_to_sheet([columns].concat(rows)); window.XLSX.utils.book_append_sheet(workbook, worksheet, 'Export'); window.XLSX.writeFile(workbook, title.replace(/\s+/g, '_') + '.xlsx'); return; } const html = buildTableHtml(columns, rows, title); downloadBlob(title.replace(/\s+/g, '_') + '.xls', 'application/vnd.ms-excel;charset=utf-8', html); } function exportRowsToCsv(title, columns, rows) { const content = [columns.map(csvEscape).join(';')].concat(rows.map(function(row) { return row.map(csvEscape).join(';'); })).join('\n'); downloadBlob(title.replace(/\s+/g, '_') + '.csv', 'text/csv;charset=utf-8', '\ufeff' + content); } function buildReportData(report) { if (report === 'permissions') { const rows = getFilteredPermissions().map(function(item) { return [ getPersonName(item), item.company || '', categoryLabel(item.category), toArray(item.zones).map(zoneLabel).join(', '), formatDate(item.validFrom), item.unlimited ? 'unbefristet' : formatDate(item.validTo), permissionStatus(item).label, item.badgeNumber || '' ]; }); return { title: 'Zutrittsberechtigungen', columns: ['Person', 'Firma', 'Kategorie', 'Zonen', 'Gültig ab', 'Gültig bis', 'Status', 'Badge'], rows: rows }; } if (report === 'access-log') { const rows = getFilteredAccessLogs().map(function(item) { return [ formatDateTime(item.at), item.personName || '', item.company || '', item.type === 'in' ? 'Eintritt' : 'Austritt', item.gate || '', item.zone || '', item.method || '', item.guard || '' ]; }); return { title: 'Zutrittsprotokoll', columns: ['Zeitpunkt', 'Person', 'Firma', 'Aktion', 'Gate', 'Zone', 'Methode', 'Bewachung'], rows: rows }; } if (report === 'briefings') { const rows = getFilteredBriefingRecords().map(function(item) { return [ item.personName || '', item.company || '', briefingTypeLabel(item.type), languageLabel(item.language), formatDateTime(item.completedAt), formatDate(item.nextDueAt), briefingStatus(item).label, String(item.score || 0) ]; }); return { title: 'Unterweisungsnachweise', columns: ['Person', 'Firma', 'Typ', 'Sprache', 'Abschluss', 'Nächste Fälligkeit', 'Status', 'Quizscore'], rows: rows }; } if (report === 'psa') { const rows = getFilteredPsaChecks().map(function(item) { return [ formatDateTime(item.checkedAt), item.personName || '', item.company || '', item.zoneName || '', item.inspector || '', toArray(item.violations).join(', '), escalationLabel(item.escalationLevel) ]; }); return { title: 'PSA-Kontrollen', columns: ['Zeitpunkt', 'Person', 'Firma', 'Zone', 'Kontrolleur', 'Verstöße', 'Eskalation'], rows: rows }; } if (report === 'incidents') { const rows = getFilteredIncidents().map(function(item) { return [ formatDateTime(item.occurredAt), item.title || incidentTypeLabel(item.type), incidentTypeLabel(item.type), item.zoneName || '', severityLabel(item.severity), incidentStatusLabel(item.status), item.guardCompany || '', formatMoney(item.damageAmount || 0) ]; }); return { title: 'Vorfallbericht', columns: ['Zeitpunkt', 'Titel', 'Typ', 'Zone', 'Schweregrad', 'Status', 'Bewachung', 'Schaden'], rows: rows }; } if (report === 'visitors') { const rows = getFilteredVisitors(false).map(function(item) { return [ item.name || '', item.company || '', item.reason || '', item.contactPersonName || '', item.area || '', item.escorted ? 'Ja' : 'Nein', item.status || '', formatDateTime(item.registeredAt) ]; }); return { title: 'Besucherlogbuch', columns: ['Name', 'Firma', 'Anlass', 'Kontaktperson', 'Bereich', 'Begleitpflicht', 'Status', 'Registriert'], rows: rows }; } return { title: 'Export', columns: ['Hinweis'], rows: [['Keine Daten']] }; } function exportReport(report, format) { const data = buildReportData(report); if (format === 'excel') { exportRowsToExcel(data.title, data.columns, data.rows); return; } if (format === 'csv') { exportRowsToCsv(data.title, data.columns, data.rows); return; } const html = buildTableHtml(data.columns, data.rows, data.title); exportHtmlToPdf(data.title, html); } function exportSingleRecord(recordType, id) { if (recordType === 'incident') { const item = getIncidentById(id); if (!item) { return; } const html = [ '

Vorfallbericht

', '

Erstellt am ' + escapeHtml(formatDateTime(nowIso())) + '

', '

Titel: ' + escapeHtml(item.title || incidentTypeLabel(item.type)) + '

', '

Typ: ' + escapeHtml(incidentTypeLabel(item.type)) + '

', '

Zeitpunkt: ' + escapeHtml(formatDateTime(item.occurredAt)) + '

', '

Zone: ' + escapeHtml(item.zoneName || '—') + '

', '

Schweregrad: ' + escapeHtml(severityLabel(item.severity)) + '

', '

Status: ' + escapeHtml(incidentStatusLabel(item.status)) + '

', '

Gemeldet von: ' + escapeHtml(item.reportedBy || '—') + '

', '

Beschreibung:
' + nl2br(item.description || '—') + '

', '

Zeugen:
' + nl2br(toArray(item.witnesses).join('\n') || '—') + '

', '

Maßnahmen:
' + nl2br(toArray(item.measures).join('\n') || '—') + '

' ].join(''); exportHtmlToPdf('Vorfall_' + (item.title || item.id), html); return; } if (recordType === 'psa-check') { const item = getPsaCheckById(id); if (!item) { return; } const html = [ '

PSA-Kontrollbericht

', '

Erstellt am ' + escapeHtml(formatDateTime(nowIso())) + '

', '

Person: ' + escapeHtml(item.personName || '—') + '

', '

Firma: ' + escapeHtml(item.company || '—') + '

', '

Zone: ' + escapeHtml(item.zoneName || '—') + '

', '

Kontrolleur: ' + escapeHtml(item.inspector || '—') + '

', '

Zeitpunkt: ' + escapeHtml(formatDateTime(item.checkedAt)) + '

', '

Verstöße:
' + nl2br(toArray(item.violations).join('\n') || 'Keine') + '

', '

Eskalation: ' + escapeHtml(escalationLabel(item.escalationLevel)) + '

', '

Notizen:
' + nl2br(item.notes || '—') + '

' ].join(''); exportHtmlToPdf('PSA_Kontrolle_' + (item.personName || item.id), html); return; } if (recordType === 'briefing') { const html = renderBriefingCertificatePreview(id); exportHtmlToPdf('Unterweisungsnachweis_' + id, html); } } async function handleFormSubmit(form) { const formName = form.getAttribute('data-form'); if (!formName) { return; } state.syncing = true; try { if (formName === 'permission-form') { await handlePermissionSave(form); } else if (formName === 'briefing-session-form') { await handleBriefingSessionSave(form); } else if (formName === 'briefing-record-form') { await handleBriefingRecordSave(form); } else if (formName === 'psa-requirement-form') { await handlePsaRequirementSave(form); } else if (formName === 'psa-check-form') { await handlePsaCheckSave(form); } else if (formName === 'psa-issue-form') { await handlePsaIssueSave(form); } else if (formName === 'visitor-form') { await handleVisitorSave(form, false); } else if (formName === 'visitor-prereg-form') { await handleVisitorSave(form, true); } else if (formName === 'incident-form') { await handleIncidentSave(form); } else if (formName === 'guard-link-form') { await handleGuardLinkSave(form); } else if (formName === 'briefing-quiz') { await handleQuizSubmit(form); } } catch (error) { showToast('Fehler', error && error.message ? error.message : 'Speichern fehlgeschlagen.'); logDebug('Form submit failed', formName, error); } finally { state.syncing = false; } } async function handleAction(action, element) { const id = element.getAttribute('data-id'); const modalType = element.getAttribute('data-modal-type'); const view = element.getAttribute('data-view'); const report = element.getAttribute('data-report'); const format = element.getAttribute('data-format'); const recordType = element.getAttribute('data-record-type'); const kind = element.getAttribute('data-kind'); try { if (action === 'switch-view') { state.currentView = view || 'dashboard'; renderApp(); return; } if (action === 'refresh') { await refresh(); return; } if (action === 'apply-filters') { renderApp(); return; } if (action === 'reset-filters') { resetFilters(); return; } if (action === 'dismiss-toast') { dismissToast(id); return; } if (action === 'close-modal' || action === 'modal-backdrop') { closeModal(); return; } if (action === 'open-modal') { openModal(modalType, { id: id, kind: kind, sessionId: element.getAttribute('data-session-id') || '', preregistration: element.getAttribute('data-preregistration') === '1' }); return; } if (action === 'permission-check-in') { await performPermissionCheckIn(id); return; } if (action === 'permission-check-out') { await performPermissionCheckOut(id); return; } if (action === 'delete-permission') { await deleteEntity('permissions/' + encodeURIComponent(id), 'Berechtigung gelöscht', 'Der Datensatz wurde gelöscht.'); return; } if (action === 'delete-briefing-session') { await deleteEntity('briefings/sessions/' + encodeURIComponent(id), 'Termin gelöscht', 'Der Unterweisungstermin wurde gelöscht.'); return; } if (action === 'delete-psa-requirement') { await deleteEntity('psa/requirements/' + encodeURIComponent(id), 'PSA-Regel gelöscht', 'Die Anforderung wurde gelöscht.'); return; } if (action === 'delete-incident') { await deleteEntity('incidents/' + encodeURIComponent(id), 'Vorfall gelöscht', 'Der Vorfall wurde gelöscht.'); return; } if (action === 'approve-prereg') { await approvePreregistration(id, true); return; } if (action === 'reject-prereg') { await approvePreregistration(id, false); return; } if (action === 'notify-contact') { await notifyContactPerson(id); return; } if (action === 'visitor-check-in') { await visitorCheckIn(id); return; } if (action === 'visitor-check-out') { await visitorCheckOut(id); return; } if (action === 'return-psa') { await returnPsa(id); return; } if (action === 'escalate-incident') { await escalateIncident(id); return; } if (action === 'export-report') { exportReport(report, format || 'pdf'); return; } if (action === 'export-single-record') { exportSingleRecord(recordType, id); return; } if (action === 'clear-signature') { const canvas = state.root.querySelector('[data-signature-canvas]'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); state.ui.signature.dataUrl = ''; } return; } if (action === 'save-signature') { await saveSignature(id); return; } } catch (error) { showToast('Fehler', error && error.message ? error.message : 'Aktion fehlgeschlagen.'); logDebug('Action failed', action, error); } } function handleRootClick(event) { const actionNode = event.target.closest('[data-action]'); if (!actionNode || !state.root.contains(actionNode)) { return; } if (actionNode.getAttribute('data-action') === 'modal-backdrop' && event.target.closest('.bgz-zk-modal')) { return; } event.preventDefault(); handleAction(actionNode.getAttribute('data-action'), actionNode); } function handleRootChange(event) { const filter = event.target.getAttribute('data-filter'); if (filter) { state.filters[filter] = event.target.value; } } function handleRootInput(event) { const filter = event.target.getAttribute('data-filter'); if (filter) { state.filters[filter] = event.target.value; } } function handleRootSubmitEvent(event) { const form = event.target.closest('form'); if (!form || !state.root.contains(form)) { return; } event.preventDefault(); handleFormSubmit(form); } function handleRootKeydown(event) { if (event.key === 'Escape' && state.activeModal) { closeModal(); } } function bindEvents() { if (!state.root) { return; } state.root.addEventListener('click', handleRootClick); state.root.addEventListener('change', handleRootChange); state.inputHandle = debounce(handleRootInput, 120); state.root.addEventListener('input', state.inputHandle); state.root.addEventListener('submit', handleRootSubmitEvent); state.root.addEventListener('keydown', handleRootKeydown); state.resizeHandle = debounce(function() { state.ui.compact = window.innerWidth < 992; }, 120); window.addEventListener('resize', state.resizeHandle); } function unbindEvents() { if (!state.root) { return; } state.root.removeEventListener('click', handleRootClick); state.root.removeEventListener('change', handleRootChange); if (state.inputHandle) { state.root.removeEventListener('input', state.inputHandle); state.inputHandle = null; } state.root.removeEventListener('submit', handleRootSubmitEvent); state.root.removeEventListener('keydown', handleRootKeydown); if (state.resizeHandle) { window.removeEventListener('resize', state.resizeHandle); } } function startPolling() { stopPolling(); if (!safeNumber(state.options.pollMs)) { return; } state.pollHandle = window.setInterval(function() { refresh(true); }, safeNumber(state.options.pollMs)); } function stopPolling() { if (state.pollHandle) { clearInterval(state.pollHandle); state.pollHandle = null; } } async function refresh(silent) { try { if (!silent) { state.loading = true; renderApp(); } await loadBootstrapData(); } catch (error) { state.loading = false; state.ui.errors.push('Aktualisierung fehlgeschlagen: ' + (error && error.message ? error.message : 'Unbekannter Fehler')); } finally { renderApp(); } } async function init(options) { state.options = mergeOptions(options); const root = resolveRoot(state.options.root); if (!root) { throw new Error('Root-Element für BauGenioZutrittskontrolle nicht gefunden.'); } if (state.initialized) { destroy(); } state.root = root; state.currentView = 'dashboard'; state.data = buildEmptyData(); state.ui.errors = []; state.ui.warnings = []; state.ui.toastQueue = []; injectStyles(); bindEvents(); state.initialized = true; renderApp(); if (state.options.autoLoad !== false) { await refresh(); } startPolling(); return window.BauGenioZutrittskontrolle; } function destroy() { stopPolling(); unbindEvents(); if (state.root) { state.root.innerHTML = ''; state.root.classList.remove('bgz-zk'); } if (!state.options.keepStylesOnDestroy) { const style = document.getElementById(STYLE_ID); if (style && style.parentNode) { style.parentNode.removeChild(style); } } state.initialized = false; state.loading = false; state.syncing = false; state.root = null; state.activeModal = null; state.resizeHandle = null; state.inputHandle = null; state.data = buildEmptyData(); state.ui.errors = []; state.ui.warnings = []; state.ui.toastQueue = []; } window.BauGenioZutrittskontrolle = { init: init, destroy: destroy, refresh: refresh }; })(); `; const printWindow = window.open('', '_blank'); printWindow.document.write(html); printWindow.document.close(); printWindow.print(); } function formatCurrency(amount) { if (typeof amount !== 'number') { amount = parseFloat(amount) || 0; } return amount.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }); } function destroy() { const container = document.getElementById('bauabrechnungPage'); if (container) { container.innerHTML = ''; } state = { currentTab: 'overview', bauabrechnungen: [], aufmass: [], lv: [], projects: [], selectedBauabrechnung: null, selectedProject: null, editingPosition: null, statistik: { offeneForderungen: 0, bezahltMonat: 0, retentionen: 0, durchschnittZahlungsziel: 0 } }; } function refresh() { loadInitialData(); } window.BauGenioBauabrechnung = { init, destroy, refresh }; })(); // BauGenio v4 — Login Bootstrap (Rescue Script) // Fixes orphaned DOMContentLoaded handler so initLogin() actually binds. // ============================================================================ (function bauGenioBootLogin() { 'use strict'; function boot() { try { var token = null; try { token = localStorage.getItem('token'); } catch(e) {} if (token && typeof loadUser === 'function') { (async function() { try { var userLoaded = await loadUser(); if (userLoaded && typeof showApp === 'function') { showApp(); } else if (typeof logout === 'function') { logout(); } } catch (err) { console.error('[Bootstrap] loadUser failed:', err); try { if (typeof logout === 'function') logout(); } catch(_) {} } })(); } else { var lc = document.getElementById('loginContainer'); if (lc) lc.classList.remove('hidden'); } if (typeof initLogin === 'function') { try { initLogin(); } catch (e) { console.error('[Bootstrap] initLogin failed:', e); } } else { console.error('[Bootstrap] initLogin is not defined — login form will not work.'); } if (typeof initDarkMode === 'function') { try { initDarkMode(); } catch (e) { console.error('[Bootstrap] initDarkMode failed:', e); } } if (typeof initNotificationBell === 'function') { try { initNotificationBell(); } catch (e) { console.error('[Bootstrap] initNotificationBell failed:', e); } } if (localStorage.getItem('darkMode') === 'true') { document.body.classList.add('dark-mode'); } if (typeof updateDarkModeButton === 'function') { try { updateDarkModeButton(); } catch (e) {} } console.log('[Bootstrap] BauGenio login bootstrap ran. initLogin present:', typeof initLogin); } catch (err) { console.error('[Bootstrap] Fatal error:', err); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })(); `; triggerDownload(filename, content, 'text/html;charset=utf-8'); } } window.BauGenioElektro = { init, destroy, refresh }; })(); ', '' ].join(''); } async function exportReport(reportType) { const payload = { type: reportType, filters: clone(state.filters), projectId: (state.data.meta || {}).projectId || EMPTY_GUID }; try { const result = await apiCall('/berichte/export', 'POST', payload); const extension = reportType === 'massenermittlung' || reportType === 'materialbedarf' ? 'xlsx' : 'pdf'; downloadBlob('trockenbau-' + reportType + '.' + extension, extension === 'pdf' ? 'application/pdf' : 'application/octet-stream', result); pushToast('Export erfolgreich vom Server erzeugt.', 'success'); return; } catch (err) { if (state.options.debug) { console.warn('exportReport fallback', err); } } if (reportType === 'massenermittlung') { const csv = buildCsv([['Bauteil', 'Raum', 'Typ', 'Fläche m²', 'Profile lfm', 'Platten m²', 'Dämmung m²']].concat(buildMassReportRows().map(function(row) { return [row.code, row.room, row.type, row.area, row.profiles, row.boards, row.insulation]; }))); downloadBlob('trockenbau-massenermittlung.csv', 'text/csv;charset=utf-8', csv); pushToast('CSV-Fallback exportiert.', 'warning'); return; } if (reportType === 'materialbedarf') { const csv = buildCsv([['Gruppe', 'Material', 'Menge', 'Einheit', 'Bauteile']].concat(state.derived.materialDemand.map(function(item) { return [item.group, item.material, item.quantity, item.unit, item.assignments]; }))); downloadBlob('trockenbau-materialbedarf.csv', 'text/csv;charset=utf-8', csv); pushToast('CSV-Fallback exportiert.', 'warning'); return; } const html = buildPrintableHtml('Trockenbau Bericht · ' + reportType, buildReportPreviewHtml(reportType)); downloadBlob('trockenbau-' + reportType + '.html', 'text/html;charset=utf-8', html); pushToast('HTML-Fallback exportiert. Für PDF produktiv Server-Rendering nutzen.', 'warning'); } async function init(target, options) { if (state.initialized) { destroy(); } resetState(); mergeOptions(options || {}); buildLookupState(); state.mountEl = resolveMountElement(target); if (!state.mountEl) { throw new Error('Mount-Element für BauGenioTrockenbau nicht gefunden.'); } injectStyles(); ensureShell(); bindEvents(); state.loading = true; render(); try { await loadBootstrapData(); buildDerivedState(); } catch (err) { pushToast('Initialisierung fehlgeschlagen: ' + err.message, 'error'); } finally { state.loading = false; state.initialized = true; state.lastRefreshAt = nowIso(); render(); } if (state.options.autoRefresh) { state.ui.autoRefreshTimer = window.setInterval(function() { refresh(); }, AUTO_REFRESH_MS); } return window.BauGenioTrockenbau; } async function refresh() { state.loading = true; render(); try { await loadBootstrapData(); buildDerivedState(); state.lastRefreshAt = nowIso(); persistCache(); } catch (err) { pushToast('Aktualisierung fehlgeschlagen: ' + err.message, 'error'); } finally { state.loading = false; render(); } } function destroy() { if (state.ui.autoRefreshTimer) { clearInterval(state.ui.autoRefreshTimer); state.ui.autoRefreshTimer = null; } unbindEvents(); removeStyles(); if (state.rootEl && state.rootEl.parentNode) { state.rootEl.parentNode.removeChild(state.rootEl); } resetState(); } window.BauGenioTrockenbau = { init: init, destroy: destroy, refresh: refresh }; })(); ' ].join('')); popup.document.close(); popup.focus(); } async function exportReport(key) { state.loading.exporting = true; render(); const context = createReportContext(); try { switch (key) { case 'abbund_excel': return exportAbbundExcel(context); case 'holzschutz_pdf': return exportHolzschutzPdf(context); case 'montage_pdf': return exportMontagePdf(context); case 'verbindung_csv': return exportVerbindungCsv(context); case 'material_csv': return exportMaterialCsv(context); case 'bauphysik_pdf': return exportBauphysikPdf(context); default: addNotification('warning', 'Unbekannter Exporttyp: ' + key); return null; } } finally { state.loading.exporting = false; render(); } } function exportAbbundExcel(context) { const rows = [ ['Abbundliste', 'Bauteil', 'Typ', 'Holzart', 'Qualität', 'Breite [mm]', 'Höhe [mm]', 'Länge [mm]', 'Stückzahl', 'Volumen [m³]', 'Status'] ]; context.bauteile.forEach(function(item) { rows.push([ item.abbundlisteNr, item.nummer, item.typ, item.holzart, item.qualitaet, item.querschnittBreiteMm, item.querschnittHoeheMm, item.laengeMm, item.stueckzahl, item.volumen, resolveStatusMeta(item.status).label ]); }); exportCsvFile('abbundlisten_und_stuecklisten.csv', rows); addNotification('success', 'Abbundlisten/Excel exportiert.'); } function exportHolzschutzPdf(context) { const body = [ '

Holzschutznachweis

', '
Erstellt am ', formatDateTime(context.createdAt), '
', '

Kennzahlen

', '

Offene Maßnahmen: ', formatNumber((context.kpis.holzschutz || {}).offen || 0, 0), ' · ', 'Geprüft: ', formatNumber((context.kpis.holzschutz || {}).geprueft || 0, 0), '

', '

Maßnahmen

', '', context.holzschutz.map(function(item) { return ''; }).join(''), '
ElementMaßnahmeGKNormZulassungStatus
' + escapeHtml(item.elementNummer) + '' + escapeHtml(item.massnahme) + '' + escapeHtml(item.gebrauchsklasse) + '' + escapeHtml(item.normHinweis) + '' + escapeHtml(item.dibtZulassung || '—') + '' + escapeHtml(resolveStatusMeta(item.status).label) + '
' ].join(''); openPrintableWindow('Holzschutznachweis', body); addNotification('success', 'Holzschutz-PDF-Ansicht geöffnet.'); } function exportMontagePdf(context) { const body = [ '

Montageprotokoll

', '
Erstellt am ', formatDateTime(context.createdAt), '
', '

Montagevorgänge

', '', context.montage.map(function(item) { return ''; }).join(''), '
ElementReihenfolgeKranLot [mm]Flucht [mm]Moment [Nm]Status
' + escapeHtml(item.elementNummer) + '' + formatNumber(item.reihenfolge, 0) + '' + escapeHtml(item.kranstellplatz) + '' + formatNumber(item.lotAbweichungMm, 1) + '' + formatNumber(item.fluchtAbweichungMm, 1) + '' + formatNumber(item.anzugsmomentNm, 0) + '' + escapeHtml(resolveStatusMeta(item.status).label) + '
', '

Witterungsschutz / Luftdichtheit

', '', context.montage.map(function(item) { return ''; }).join(''), '
ElementWinddichtheitDampfbremseFugendichtungWitterungsschutz
' + escapeHtml(item.elementNummer) + '' + escapeHtml(resolveStatusMeta(item.winddichtStatus).label) + '' + escapeHtml(resolveStatusMeta(item.dampfbremseStatus).label) + '' + escapeHtml(resolveStatusMeta(item.fugendichtungStatus).label) + '' + escapeHtml(item.witterungsschutz || '—') + '
' ].join(''); openPrintableWindow('Montageprotokoll', body); addNotification('success', 'Montageprotokoll geöffnet.'); } function exportVerbindungCsv(context) { const rows = [ ['Knotenpunkt', 'Bauteil', 'Typ', 'Anzahl', 'Durchmesser [mm]', 'Moment [Nm]', 'Dokumentiert', 'Status', 'Prüfer', 'Datum'] ]; context.verbindungen.forEach(function(item) { rows.push([ item.knotenpunkt, item.bauteilNummer, item.typ, item.anzahl, item.durchmesserMm, item.momentNm, item.dokumentiert ? 'Ja' : 'Nein', resolveStatusMeta(item.status).label, item.pruefer, formatDate(item.datum) ]); }); exportCsvFile('verbindungsmittel_nachweis.csv', rows); addNotification('success', 'Verbindungsmittel-Nachweis exportiert.'); } function exportMaterialCsv(context) { const rows = [['Holzart', 'Qualität', 'Anzahl Positionen', 'Volumen [m³]']]; context.material.forEach(function(item) { rows.push([ item.holzart, item.qualitaet, item.anzahl, item.volumen ]); }); exportCsvFile('materialstatistik_holzbau.csv', rows); addNotification('success', 'Materialstatistik exportiert.'); } function exportBauphysikPdf(context) { const body = [ '

Bauphysik-Nachweise

', '
Erstellt am ', formatDateTime(context.createdAt), '
', '

Elemente

', '', context.elemente.map(function(item) { return ''; }).join(''), '
ElementDiffusionFeuer [min]Schall [dB]Feuchte [%]
' + escapeHtml(item.nummer) + '' + escapeHtml(item.diffusion.bewertung + ' (Σsd ' + item.diffusion.score + ')') + '' + formatNumber(item.feuerwiderstandMin, 0) + '' + formatNumber(item.schallschutzDb, 1) + '' + formatNumber(item.feuchtigkeitProzent, 1) + '
' ].join(''); openPrintableWindow('Bauphysik-Nachweise', body); addNotification('success', 'Bauphysik-PDF-Ansicht geöffnet.'); } async function exportMasterReport() { state.loading.exporting = true; render(); try { const context = createReportContext(); const summary = { erstelltAm: context.createdAt, kpis: context.kpis, bauteile: context.bauteile.length, elemente: context.elemente.length, werkstatt: context.werkstatt.length, feuchte: context.feuchte.length, holzschutz: context.holzschutz.length, montage: context.montage.length, verbindungen: context.verbindungen.length, dach: context.dach.length }; downloadTextFile('holzbau_masterreport.json', JSON.stringify(summary, null, 2), 'application/json;charset=utf-8'); addNotification('success', 'Sammelreport exportiert.'); } finally { state.loading.exporting = false; render(); } } async function exportViewSnapshot(view) { const filename = 'holzbau_' + view + '_snapshot.html'; const content = '' + renderActiveView() + ' '; downloadTextFile(filename, content, 'text/html;charset=utf-8'); addNotification('success', 'Ansicht exportiert: ' + view); } /* ========================================================================== Events ========================================================================== */ function bindEvents() { unbindEvents(); state.handlers.click = function(event) { const actionEl = event.target.closest('[data-action]'); if (!actionEl || !state.root.contains(actionEl)) { return; } event.preventDefault(); handleAction(actionEl.getAttribute('data-action'), actionEl); }; state.handlers.change = function(event) { const selectFilter = event.target.closest('[data-filter-select]'); if (selectFilter) { state.filters[selectFilter.getAttribute('data-filter-select')] = event.target.value; deriveState(); render(); return; } const toggleFilter = event.target.closest('[data-filter-toggle]'); if (toggleFilter) { state.filters[toggleFilter.getAttribute('data-filter-toggle')] = !!event.target.checked; deriveState(); render(); return; } const linkedField = event.target.name === 'elementId' ? event.target.form && event.target.form.querySelector('[name="elementNummer"]') : event.target.name === 'bauteilId' ? event.target.form && event.target.form.querySelector('[name="bauteilNummer"]') : null; if (linkedField) { if (event.target.name === 'elementId') { const element = byId('elemente', event.target.value); if (element) { linkedField.value = element.nummer; } } if (event.target.name === 'bauteilId') { const bauteil = byId('bauteile', event.target.value); if (bauteil) { linkedField.value = bauteil.nummer; } } } }; state.handlers.input = debounce(function(event) { const filterInput = event.target.closest('[data-filter-input]'); if (filterInput) { state.filters[filterInput.getAttribute('data-filter-input')] = event.target.value; deriveState(); render(); } }, state.options.debounceMs); state.handlers.submit = function(event) { const form = event.target.closest('form[data-form]'); if (!form) { return; } event.preventDefault(); const formName = form.getAttribute('data-form'); const payload = serializeForm(form); handleFormSubmit(formName, payload); }; state.root.addEventListener('click', state.handlers.click); state.root.addEventListener('change', state.handlers.change); state.root.addEventListener('input', state.handlers.input); state.root.addEventListener('submit', state.handlers.submit); } function unbindEvents() { if (!state.root) { return; } if (state.handlers.click) { state.root.removeEventListener('click', state.handlers.click); } if (state.handlers.change) { state.root.removeEventListener('change', state.handlers.change); } if (state.handlers.input) { state.root.removeEventListener('input', state.handlers.input); } if (state.handlers.submit) { state.root.removeEventListener('submit', state.handlers.submit); } state.handlers.click = null; state.handlers.change = null; state.handlers.input = null; state.handlers.submit = null; } function handleFormSubmit(formName, payload) { switch (formName) { case 'bauteil': saveBauteil(payload).then(render); break; case 'element': saveElement(payload).then(render); break; case 'feuchte': saveFeuchtemessung(payload).then(render); break; case 'holzschutz': saveHolzschutz(payload).then(render); break; case 'montage': saveMontage(payload).then(render); break; case 'verbindung': saveVerbindung(payload).then(render); break; case 'dach': saveDach(payload).then(render); break; case 'werkstatt': saveWerkstatt(payload).then(render); break; case 'abbund-import': importAbbund(payload).then(render); break; default: addNotification('warning', 'Unbekanntes Formular: ' + formName); render(); } } function handleAction(action, el) { const id = el.getAttribute('data-id'); const view = el.getAttribute('data-view'); const reportKey = el.getAttribute('data-report'); switch (action) { case 'switch-view': state.ui.activeView = view || VIEW_KEYS.DASHBOARD; deriveState(); render(); break; case 'refresh': refresh(); break; case 'load-demo': applyDataset(buildMockDataset()); deriveState(); hydrateSelections(); addNotification('success', 'Demo-Daten geladen.'); render(); break; case 'reset-filters': state.filters = createInitialState().filters; deriveState(); render(); break; case 'dismiss-notification': removeNotification(id); render(); break; case 'toggle-bauteil-form': resetFormVisibility(); state.ui.showBauteilForm = !state.ui.showBauteilForm; state.ui.editContext.bauteilId = null; render(); break; case 'toggle-element-form': resetFormVisibility(); state.ui.showElementForm = !state.ui.showElementForm; state.ui.editContext.elementId = null; render(); break; case 'toggle-feuchte-form': resetFormVisibility(); state.ui.showFeuchteForm = !state.ui.showFeuchteForm; render(); break; case 'toggle-holzschutz-form': resetFormVisibility(); state.ui.showHolzschutzForm = !state.ui.showHolzschutzForm; render(); break; case 'toggle-montage-form': resetFormVisibility(); state.ui.showMontageForm = !state.ui.showMontageForm; state.ui.editContext.montageId = null; render(); break; case 'toggle-verbindung-form': resetFormVisibility(); state.ui.showVerbindungForm = !state.ui.showVerbindungForm; state.ui.editContext.verbindungId = null; render(); break; case 'toggle-dach-form': resetFormVisibility(); state.ui.showDachForm = !state.ui.showDachForm; state.ui.editContext.dachId = null; render(); break; case 'toggle-werkstatt-form': resetFormVisibility(); state.ui.showWerkstattForm = !state.ui.showWerkstattForm; state.ui.editContext.werkstattId = null; render(); break; case 'cancel-bauteil-form': case 'cancel-element-form': case 'cancel-feuchte-form': case 'cancel-holzschutz-form': case 'cancel-montage-form': case 'cancel-verbindung-form': case 'cancel-dach-form': case 'cancel-werkstatt-form': resetFormVisibility(); resetEditContext(); render(); break; case 'select-bauteil': selectBauteil(id); render(); break; case 'select-element': selectElement(id); render(); break; case 'select-montage': selectMontage(id); render(); break; case 'select-verbindung': selectVerbindung(id); render(); break; case 'select-dach': selectDach(id); render(); break; case 'select-werkstatt': selectWerkstatt(id); render(); break; case 'edit-bauteil': selectBauteil(id); resetFormVisibility(); state.ui.showBauteilForm = true; state.ui.editContext.bauteilId = id; render(); break; case 'edit-element': selectElement(id); resetFormVisibility(); state.ui.showElementForm = true; state.ui.editContext.elementId = id; render(); break; case 'edit-werkstatt': selectWerkstatt(id); resetFormVisibility(); state.ui.showWerkstattForm = true; state.ui.editContext.werkstattId = id; render(); break; case 'edit-montage': selectMontage(id); resetFormVisibility(); state.ui.showMontageForm = true; state.ui.editContext.montageId = id; render(); break; case 'edit-verbindung': selectVerbindung(id); resetFormVisibility(); state.ui.showVerbindungForm = true; state.ui.editContext.verbindungId = id; render(); break; case 'edit-dach': selectDach(id); resetFormVisibility(); state.ui.showDachForm = true; state.ui.editContext.dachId = id; render(); break; case 'delete-bauteil': deleteBauteil(id).then(render); break; case 'delete-element': deleteElement(id).then(render); break; case 'trigger-import': state.ui.activeView = VIEW_KEYS.WERKSTATT; addNotification('info', 'Importformular oben geöffnet.'); render(); break; case 'copy-module-state': copyToClipboard(JSON.stringify({ filters: state.filters, view: state.ui.activeView, kpis: state.computed.kpis }, null, 2)).then(function() { addNotification('success', 'State-Auszug in die Zwischenablage kopiert.'); render(); }); break; case 'copy-montage-protocol': { const item = byId('montageVorgaenge', id); if (item) { copyToClipboard('Richtprotokoll ' + item.elementNummer + '\nLot: ' + item.lotAbweichungMm + ' mm\nFlucht: ' + item.fluchtAbweichungMm + ' mm\nHöhenkote: ' + item.hoehenkoteMm + ' mm').then(function() { addNotification('success', 'Montageprotokoll kopiert.'); render(); }); } break; } case 'copy-werkstatt-protocol': { const item = byId('werkstattAuftraege', id); if (item) { copyToClipboard(item.protokollText || '').then(function() { addNotification('success', 'Werkstattprotokoll kopiert.'); render(); }); } break; } case 'copy-verbindung-row': { const item = byId('verbindungen', id); if (item) { copyToClipboard(JSON.stringify(item, null, 2)).then(function() { addNotification('success', 'Verbindungsdatensatz kopiert.'); render(); }); } break; } case 'copy-protokoll': { const item = state.data.protokolle.find(function(row) { return row.id === id; }); if (item) { copyToClipboard(item.text || '').then(function() { addNotification('success', 'Protokolltext kopiert.'); render(); }); } break; } case 'export-report': exportReport(reportKey); break; case 'export-master': exportMasterReport(); break; case 'export-view': exportViewSnapshot(view || state.ui.activeView); break; default: addNotification('warning', 'Aktion nicht implementiert: ' + action); render(); } } /* ========================================================================== Styles ========================================================================== */ function injectStyles() { let styleEl = document.getElementById(STYLE_ID); if (styleEl) { return styleEl; } styleEl = document.createElement('style'); styleEl.id = STYLE_ID; styleEl.type = 'text/css'; styleEl.textContent = ` .bgz-holzbau { --bgz-border: #dde4ee; --bgz-muted: #6c7a89; --bgz-soft: #f5f7fb; --bgz-card: #ffffff; --bgz-primary: #0d6efd; --bgz-success: #198754; --bgz-warning: #ffc107; --bgz-danger: #dc3545; color: #18212f; } .bgz-holzbau .card { border-radius: 1rem; overflow: hidden; } .bgz-holzbau .card-header { border-bottom: 1px solid var(--bgz-border); padding: 0.9rem 1rem; } .bgz-holzbau .card-body { padding: 1rem; } .bgz-holzbau .bgz-nav-pills { background: #fff; border-radius: 999px; padding: 0.4rem; box-shadow: 0 6px 24px rgba(17, 24, 39, 0.06); overflow-x: auto; flex-wrap: nowrap; gap: 0.25rem; } .bgz-holzbau .bgz-nav-pills .nav-link { border-radius: 999px; white-space: nowrap; color: #344054; font-weight: 600; } .bgz-holzbau .bgz-nav-pills .nav-link.active { box-shadow: inset 0 0 0 1px rgba(255,255,255,0.25); } .bgz-holzbau .bgz-view-container { min-height: 480px; } .bgz-holzbau .bgz-kpi-card { background: linear-gradient(180deg, #ffffff, #fbfcff); } .bgz-holzbau .bgz-kpi-value { font-size: 1.4rem; font-weight: 700; line-height: 1.2; margin-top: 0.2rem; } .bgz-holzbau .bgz-progress { height: 0.6rem; border-radius: 999px; } .bgz-holzbau .bgz-status-badge { font-weight: 600; } .bgz-holzbau .bgz-prop { background: var(--bgz-soft); border: 1px solid var(--bgz-border); border-radius: 0.85rem; padding: 0.7rem 0.8rem; min-height: 100%; } .bgz-holzbau .bgz-prop-label { color: var(--bgz-muted); font-size: 0.76rem; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; margin-bottom: 0.25rem; } .bgz-holzbau .bgz-prop-value { font-weight: 600; word-break: break-word; } .bgz-holzbau .table { margin-bottom: 0; } .bgz-holzbau .table > :not(caption) > * > * { border-bottom-color: var(--bgz-border); padding-top: 0.55rem; padding-bottom: 0.55rem; } .bgz-holzbau .table thead th { font-size: 0.76rem; text-transform: uppercase; color: var(--bgz-muted); letter-spacing: 0.04em; white-space: nowrap; } .bgz-holzbau .table td { vertical-align: middle; } .bgz-holzbau .btn-light { border-color: var(--bgz-border); background: #fff; } .bgz-holzbau .btn-group-sm > .btn, .bgz-holzbau .btn-sm { border-radius: 0.65rem; } .bgz-holzbau .form-control, .bgz-holzbau .form-select { border-radius: 0.8rem; border-color: var(--bgz-border); box-shadow: none; } .bgz-holzbau textarea.form-control { min-height: 2.75rem; } .bgz-holzbau .form-control:focus, .bgz-holzbau .form-select:focus { border-color: rgba(13, 110, 253, 0.35); box-shadow: 0 0 0 0.22rem rgba(13, 110, 253, 0.08); } .bgz-holzbau .bgz-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.35); display: flex; align-items: center; justify-content: center; z-index: 1080; padding: 1rem; } .bgz-holzbau .bgz-photo-placeholder { display: flex; min-height: 120px; background: linear-gradient(180deg, #f9fbff, #eef4ff); border: 1px dashed #c8d4e5; border-radius: 0.9rem; padding: 1rem; flex-direction: column; align-items: center; justify-content: center; text-align: center; } .bgz-holzbau .list-group-item { border-color: var(--bgz-border); } .bgz-holzbau .font-monospace { font-size: 0.85rem; } .bgz-holzbau .text-uppercase.small { letter-spacing: 0.04em; } .bgz-holzbau .spinner-border { width: 2.1rem; height: 2.1rem; } .bgz-holzbau .alert { border-radius: 0.9rem; } .bgz-holzbau .badge { border-radius: 999px; font-weight: 600; } .bgz-holzbau .bgz-toolbar .btn { min-width: 138px; } .bgz-holzbau .btn { border-radius: 0.8rem; font-weight: 600; } .bgz-holzbau .table-primary > * { --bs-table-bg: rgba(13, 110, 253, 0.06); } .bgz-holzbau .list-group-item:first-child, .bgz-holzbau .list-group-item:last-child { border-radius: 0; } .bgz-holzbau .card.shadow-sm { box-shadow: 0 10px 28px rgba(17, 24, 39, 0.06) !important; } .bgz-holzbau .bgz-data-table tbody tr:hover { background: rgba(13, 110, 253, 0.03); } .bgz-holzbau .nav-link { cursor: pointer; } .bgz-holzbau .btn-link { text-decoration: none; font-weight: 600; } .bgz-holzbau .btn-link:hover { text-decoration: underline; } .bgz-holzbau .small strong { font-weight: 700; } .bgz-holzbau .table-responsive { scrollbar-width: thin; } .bgz-holzbau .bgz-sticky-actions { position: sticky; top: 1rem; } @media (max-width: 1399.98px) { .bgz-holzbau .bgz-toolbar .btn { min-width: auto; } } @media (max-width: 991.98px) { .bgz-holzbau .card-header, .bgz-holzbau .card-body { padding-left: 0.85rem; padding-right: 0.85rem; } .bgz-holzbau .bgz-kpi-value { font-size: 1.2rem; } } @media (max-width: 767.98px) { .bgz-holzbau { padding-left: 0; padding-right: 0; } .bgz-holzbau .bgz-nav-pills { border-radius: 1rem; } .bgz-holzbau .table thead { display: none; } .bgz-holzbau .table, .bgz-holzbau .table tbody, .bgz-holzbau .table tr, .bgz-holzbau .table td { display: block; width: 100%; } .bgz-holzbau .table tr { border-bottom: 1px solid var(--bgz-border); padding: 0.35rem 0; } .bgz-holzbau .table td { border: 0; padding: 0.25rem 0; } .bgz-holzbau .table td::before { content: none; } .bgz-holzbau .bgz-photo-placeholder { min-height: 100px; } } `; document.head.appendChild(styleEl); return styleEl; } function removeStyles() { const styleEl = document.getElementById(STYLE_ID); if (styleEl && styleEl.parentNode) { styleEl.parentNode.removeChild(styleEl); } } /* ========================================================================== Lifecycle ========================================================================== */ async function init(target, options) { if (state.initialized) { unbindEvents(); } assignOptions(options); restoreUiState(); injectStyles(); const root = resolveRoot(target); if (!root) { throw new Error('BauGenioHolzbau: Root-Element nicht gefunden.'); } state.root = root; state.initialized = true; state.root.setAttribute('data-bgz-holzbau-mounted', 'true'); render(); bindEvents(); if (state.options.autoLoad) { await loadAllData(false); render(); } return window.BauGenioHolzbau; } function destroy() { unbindEvents(); if (state.root) { state.root.innerHTML = ''; state.root.removeAttribute('data-bgz-holzbau-mounted'); } removeStyles(); const fresh = createInitialState(); Object.keys(state).forEach(function(key) { delete state[key]; }); Object.keys(fresh).forEach(function(key) { state[key] = fresh[key]; }); } async function refresh() { if (!state.initialized) { return; } await loadAllData(false); render(); } window.BauGenioHolzbau = { init: init, destroy: destroy, refresh: refresh }; })(); ', '' ].join('')); win.document.close(); win.focus(); setTimeout(function() { win.print(); }, 250); } function buildPlantDataSheetHtml(plantId) { const plant = plantId ? getAnlageById(plantId) : getFilteredPlants()[0]; if (!plant) { return '

Kein Datensatz

Es wurde keine Anlage gefunden.

'; } const summary = calculatePlantSummary(plant); const roofs = getRoofsByPlantId(plant.id); const fields = getFieldsByPlantId(plant.id); const strings = getStringsByPlantId(plant.id); const inverters = getInvertersByPlantId(plant.id); const storages = getStoragesByPlantId(plant.id); const registration = getRegistrationByPlantId(plant.id); return [ '

Anlagendatenblatt – ', escapeHtml(plant.name), '

', '
Stand ', escapeHtml(formatDateTime(nowTimestamp())), '
', '
', '
Standort
', escapeHtml(plant.standort || '—'), '
', '
Status
', escapeHtml(plant.status || '—'), '
', '
Geplante Leistung
', escapeHtml(asKwP(summary.power.plannedKwP)), '
', '
Installierte Leistung
', escapeHtml(asKwP(summary.power.installedKwP)), '
', '
Module in Betrieb
', escapeHtml(asNumber(summary.modules.commissioned, 0)), '
', '
Messkonzept
', escapeHtml(firstDefined(registration && registration.messkonzept, plant.messkonzept, '—')), '
', '
', '

Dachflächen

', '', roofs.map(function(roof) { return ''; }).join(''), '
NameAusrichtungNeigungFlächeGeplantInstalliert
' + escapeHtml(roof.name) + '' + escapeHtml(roof.ausrichtung) + '' + escapeHtml(asDecimal(roof.neigung, 0) + '°') + '' + escapeHtml(asSquareMeter(roof.flaeche, 1)) + '' + escapeHtml(asKwP(getRoofPlannedKwP(roof.id))) + '' + escapeHtml(asKwP(getRoofInstalledKwP(roof.id))) + '
', '

Belegungsfelder

', '', fields.map(function(field) { const module = getFieldModuleType(field); return ''; }).join(''), '
FeldModultypBelegungMontiertAngeschlossenIn Betrieb
' + escapeHtml(field.name) + '' + escapeHtml(module ? module.name : '—') + '' + escapeHtml(field.reihen + ' × ' + field.spalten) + '' + escapeHtml(asNumber(getFieldMountedModules(field), 0)) + '' + escapeHtml(asNumber(getFieldConnectedModules(field), 0)) + '' + escapeHtml(asNumber(getFieldCommissionedModules(field), 0)) + '
', '

Strings & Wechselrichter

', '', strings.map(function(plan) { const e = calculateStringElectrical(plan); const wr = getInverterById(plan.inverterId); return ''; }).join(''), '
StringMPPVmppVocWR
' + escapeHtml(plan.name) + '' + escapeHtml(plan.mppTracker || '—') + '' + escapeHtml(asDecimal(e.vmppString, 1) + ' V') + '' + escapeHtml(asDecimal(e.vocString, 1) + ' V') + '' + escapeHtml(wr ? wr.name : '—') + '
', '

Speicher

', '', storages.map(function(storage) { return ''; }).join('') || '', '
NameTypChemieKapazitätLeistungNotstrom
' + escapeHtml(storage.name) + '' + escapeHtml(storage.typ || '—') + '' + escapeHtml(storage.chemie || '—') + '' + escapeHtml(asKwh(storage.kapazitaetKwh, 1)) + '' + escapeHtml(asKw(storage.leistungKw)) + '' + escapeHtml(toBool(storage.notstrom) ? 'Ja' : 'Nein') + '
Keine Speicher hinterlegt.
', '
Hinweis: Dieses Datenblatt basiert auf den im Modul hinterlegten Betriebs- und Planungsdaten. Rechtlich relevante Dokumente sollten serverseitig versioniert erzeugt werden.
' ].join(''); } function buildStringPlanHtml(fieldId) { const field = fieldId ? getRoofFieldById(fieldId) : filterCollectionByPlant('roofFields', state.data.roofFields)[0]; if (!field) { return '

Kein Stringplan

Es wurde kein Belegungsfeld gefunden.

'; } const roof = getRoofById(field.roofId); const plant = roof ? getAnlageById(roof.anlageId) : null; const strings = (state.data.stringPlans || []).filter(function(item) { return safeString(item.roofFieldId) === safeString(field.id); }); return [ '

String- und Belegungsplan – ', escapeHtml(field.name), '

', '
', escapeHtml(plant ? plant.name : '—'), ' · ', escapeHtml(roof ? roof.name : '—'), '
', '

Belegungsdaten

', '', '', '', '', '', '', '
Feld', escapeHtml(field.name), '
Modultyp', escapeHtml((getFieldModuleType(field) || {}).name || '—'), '
Belegung', escapeHtml(field.reihen + ' × ' + field.spalten), '
Randabstand', escapeHtml(asDecimal(field.randabstandCm, 1) + ' cm'), '
Reihenabstand', escapeHtml(asDecimal(field.reihenabstandCm, 1) + ' cm'), '
', '

Stringdetails

', '', strings.map(function(plan) { const e = calculateStringElectrical(plan); return ''; }).join(''), '
NameStringsModule/StringVmppVocMPP
' + escapeHtml(plan.name) + '' + escapeHtml(asNumber(plan.stringCount, 0)) + '' + escapeHtml(asNumber(plan.modulesPerString, 0)) + '' + escapeHtml(asDecimal(e.vmppString, 1) + ' V') + '' + escapeHtml(asDecimal(e.vocString, 1) + ' V') + '' + escapeHtml(plan.mppTracker || '—') + '
' ].join(''); } function buildCsvFromRows(headers, rows) { const lines = []; lines.push(headers.join(';')); rows.forEach(function(row) { lines.push(row.map(function(cell) { const text = safeString(cell).replace(/"/g, '""'); return '"' + text + '"'; }).join(';')); }); return lines.join('\n'); } function exportEconomicsExcel() { const rows = getFilteredPlants().map(function(plant) { const econ = calculatePlantEconomics(plant.id); const monitoring = calculatePlantMonitoringSummary(plant.id); return [ plant.name, plant.standort, econ.investmentNet, econ.opexAnnual, econ.annualBenefit, econ.paybackYears, econ.rendementPct, econ.lcoeCt, monitoring.totalActualKwh ]; }); const csv = buildCsvFromRows([ 'Anlage', 'Standort', 'Investition netto', 'Opex p.a.', 'Jahresnutzen', 'Amortisation', 'Rendite', 'LCOE ct/kWh', 'IST-Ertrag kWh' ], rows); downloadBlob('baugenio_pv_wirtschaftlichkeit.xls', csv, 'application/vnd.ms-excel;charset=utf-8'); pushToast('success', 'Export erstellt', 'Die Wirtschaftlichkeitsdatei wurde exportiert.'); } function exportYieldReport() { const rows = getFilteredPlants().map(function(plant) { const summary = calculatePlantMonitoringSummary(plant.id); return [ plant.name, summary.totalForecastKwh, summary.totalActualKwh, summary.specificYield, summary.prAvg, summary.availabilityAvg, summary.selfConsumptionQuote, summary.autarky ]; }); const csv = buildCsvFromRows([ 'Anlage', 'Soll kWh', 'IST kWh', 'Spez. Ertrag', 'PR', 'Verfügbarkeit', 'Eigenverbrauchsquote', 'Autarkie' ], rows); downloadBlob('baugenio_pv_ertragsbericht.csv', csv, 'text/csv;charset=utf-8'); pushToast('success', 'Export erstellt', 'Der Ertragsbericht wurde exportiert.'); } function exportDocumentation() { const rows = getFilteredPlants().map(function(plant) { const reg = getRegistrationByPlantId(plant.id); return [ plant.name, reg ? reg.netzbetreiber : '', reg ? reg.status : '', reg ? reg.anschlussleistungKw : '', reg ? reg.messkonzept : '', reg ? reg.eingereichtAm : '', reg ? reg.genehmigtAm : '' ]; }); const csv = buildCsvFromRows(['Anlage', 'Netzbetreiber', 'Status', 'Anschlussleistung kW', 'Messkonzept', 'Eingereicht', 'Genehmigt'], rows); downloadBlob('baugenio_pv_netzanmeldung.csv', csv, 'text/csv;charset=utf-8'); pushToast('success', 'Export erstellt', 'Die Netzanmeldungsdokumentation wurde exportiert.'); } function exportVdeProtocol() { const rows = filterCollectionByPlant('commissioning', state.data.commissioning).map(function(item) { const plant = getAnlageById(item.anlageId); return [ plant ? plant.name : '', item.date, item.vdeProtocolNumber, item.vocOk ? 'Ja' : 'Nein', item.iscOk ? 'Ja' : 'Nein', item.isolationOk ? 'Ja' : 'Nein', item.naProtectionOk ? 'Ja' : 'Nein', item.status ]; }); const csv = buildCsvFromRows(['Anlage', 'Datum', 'VDE-Protokoll', 'Voc OK', 'Isc OK', 'Isolation OK', 'NA-Schutz OK', 'Status'], rows); downloadBlob('baugenio_pv_vde_protokoll.csv', csv, 'text/csv;charset=utf-8'); pushToast('success', 'Export erstellt', 'Das VDE-Inbetriebnahmeprotokoll wurde exportiert.'); } function exportCo2Report() { const rows = getFilteredPlants().map(function(plant) { const econ = calculatePlantEconomics(plant.id); const monitoring = calculatePlantMonitoringSummary(plant.id); return [ plant.name, monitoring.totalActualKwh, monitoring.totalSelfConsumptionKwh, monitoring.autarky, econ.co2SavingsKg, econ.annualBenefit ]; }); const csv = buildCsvFromRows(['Anlage', 'IST-Ertrag kWh', 'Eigenverbrauch kWh', 'Autarkie', 'CO2 kg', 'Nutzen'], rows); downloadBlob('baugenio_pv_co2_bericht.csv', csv, 'text/csv;charset=utf-8'); pushToast('success', 'Export erstellt', 'Der CO₂-Bericht wurde exportiert.'); } function handleExport(exportKey, id) { if (exportKey === 'datenblatt-pdf') { openPrintWindow('PV-Datenblatt', buildPlantDataSheetHtml(id)); return; } if (exportKey === 'stringplan-pdf') { openPrintWindow('PV-Stringplan', buildStringPlanHtml(id)); return; } if (exportKey === 'ertragsbericht') { exportYieldReport(); return; } if (exportKey === 'wirtschaftlichkeit-xls') { exportEconomicsExcel(); return; } if (exportKey === 'netzanmeldung') { exportDocumentation(); return; } if (exportKey === 'vde-protokoll') { exportVdeProtocol(); return; } if (exportKey === 'co2-bericht') { exportCo2Report(); return; } pushToast('warning', 'Export unbekannt', 'Für "' + exportKey + '" ist kein Exporthandler definiert.'); } function handleRunCalculation(kind, id) { if (kind === 'string') { const plan = findById('stringPlans', id); if (!plan) { return; } const calc = calculateStringElectrical(plan); openDrawer('Stringberechnung', [ '
', '
Vmpp
', escapeHtml(asDecimal(calc.vmppString, 1) + ' V'), '
', '
Voc
', escapeHtml(asDecimal(calc.vocString, 1) + ' V'), '
', '
Impp
', escapeHtml(asDecimal(calc.imppString, 2) + ' A'), '
', '
DC-Leistung
', escapeHtml(asKw(calc.dcPowerKw)), '
', '
', '
Red-Team-Hinweis: Prüfen Sie zusätzlich kalte Voc-Grenzen, MPP-Spannungsfenster des WR und Stringasymmetrien je Tracker.
' ].join('')); return; } if (kind === 'inverter') { const inverter = findById('inverters', id); if (!inverter) { return; } const calc = calculateInverterSizing(inverter); openDrawer('Wechselrichter-Auslegung', [ '
', '
DC-Leistung
', escapeHtml(asKw(calc.dcPowerKw)), '
', '
AC-Leistung
', escapeHtml(asKw(calc.acPowerKw)), '
', '
DC/AC
', escapeHtml(asDecimal(calc.dcAcRatio, 2)), '
', '
Tracker genutzt
', escapeHtml(asNumber(calc.trackerUsed, 0)), '
', '
', '
Gegenargument: Ein hohes DC/AC-Verhältnis verbessert nicht automatisch die Wirtschaftlichkeit. Clipping, Lastprofil und Netzvorgaben mitprüfen.
' ].join('')); return; } if (kind === 'storage') { const storage = findById('storages', id); if (!storage) { return; } const calc = calculateStorageSummary(storage); openDrawer('Speicherauslegung', [ '
', '
Nutzbar
', escapeHtml(asKwh(calc.usableKwh, 1)), '
', '
Abdeckung
', escapeHtml(asPercent(calc.storageCoveragePct, 1)), '
', '
Peak-Support
', escapeHtml(asKw(calc.peakSupportKw)), '
', '
EV-relevant
', escapeHtml(asKwh(calc.selfUseRelevantKwh, 0)), '
', '
', '
Risikofilter: Zu große Speicher binden Kapital mit sinkender Grenzrendite. Prüfen Sie Lastprofil, Notstrombedarf und Wallbox-Integration separat.
' ].join('')); return; } if (kind === 'eeg') { openDrawer('EEG- / Vergütungscheck', '
Vergütungs- und Direktvermarktungsparameter werden aus Netzanmeldung, Einspeisung und Marktprämie neu bewertet. Prüfen Sie aktuelle regulatorische Vorgaben separat.
'); return; } if (kind === 'economics') { const plant = getAnlageById(id); if (!plant) { return; } const econ = calculatePlantEconomics(plant.id); openDrawer('Wirtschaftlichkeitsrechnung', [ '
', '
Investition
', escapeHtml(asCurrency(econ.investmentNet)), '
', '
Opex p.a.
', escapeHtml(asCurrency(econ.opexAnnual)), '
', '
Nutzen p.a.
', escapeHtml(asCurrency(econ.annualBenefit)), '
', '
Payback
', escapeHtml(asDecimal(econ.paybackYears, 1) + ' Jahre'), '
', '
', '
Absicherung: Sensitivität für Strompreis, Vergütung, Verschattung und Opex ist Pflicht, sonst unterschätzen Sie das Downside-Risiko.
' ].join('')); } } function renderModal() { if (!state.ui.modal.open) { return ''; } const record = state.cache.currentFormRecord || createEmptyRecord(state.ui.modal.entity); return [ '
', ' ', '
' ].join(''); } function renderDrawer() { if (!state.ui.drawer.open) { return ''; } return [ '
', ' ', '
' ].join(''); } function renderToastStack() { return [ '
', (state.ui.toasts || []).map(function(toast) { return [ '
', '
', escapeHtml((toast.type || 'info').charAt(0).toUpperCase()), '
', '
', '
', escapeHtml(toast.title || 'Hinweis'), '
', '
', escapeHtml(toast.text || ''), '
', '
', '
' ].join(''); }).join(''), '
' ].join(''); } function renderToasts() { const stack = qs('.bgz-pv-toast-stack'); if (stack) { stack.outerHTML = renderToastStack(); } else { render(); } } function renderActiveView() { if (state.ui.loading) { return renderLoadingState(); } if (state.ui.activeView === VIEW_IDS.DASHBOARD) { return renderDashboardView(); } if (state.ui.activeView === VIEW_IDS.PLANNING) { return renderPlanningView(); } if (state.ui.activeView === VIEW_IDS.INSTALLATION) { return renderInstallationView(); } if (state.ui.activeView === VIEW_IDS.STORAGE) { return renderStorageView(); } if (state.ui.activeView === VIEW_IDS.REGISTRATION) { return renderRegistrationView(); } if (state.ui.activeView === VIEW_IDS.MONITORING) { return renderMonitoringView(); } if (state.ui.activeView === VIEW_IDS.REPORTS) { return renderReportsView(); } if (state.ui.activeView === VIEW_IDS.CATALOGS) { return renderCatalogsView(); } return renderEmptyState('Ansicht unbekannt', 'Die angeforderte Ansicht ist nicht verfügbar.'); } function renderApp() { return [ '
', '
', ' ', renderHeader(), '
', ' ', renderSidebar(), '
', renderActiveView(), '
', '
', '
', ' ', renderModal(), ' ', renderDrawer(), ' ', renderToastStack(), '
' ].join(''); } function render() { if (!state.root) { return; } const html = renderApp(); state.cache.lastRenderedHtml = html; state.root.innerHTML = html; } function applyFilter(name, value) { state.filters[name] = value; computeDerivedState(); render(); } function resetFilters() { state.filters = { search: '', gebaeude: '', standort: '', status: '', leistungsklasse: '', dachflaeche: '', wechselrichterTyp: '', speicherTyp: '', netzStatus: '', groupBy: '', commissioningYear: '' }; computeDerivedState(); render(); } const debouncedSearch = debounce(function(value) { applyFilter('search', value); }, 180); function handleRootClick(event) { const actionNode = event.target.closest('[data-action]'); if (!actionNode) { return; } const action = actionNode.getAttribute('data-action'); if (action === 'switch-view') { state.ui.activeView = actionNode.getAttribute('data-view'); render(); return; } if (action === 'switch-subtab') { setNestedSubtab(actionNode.getAttribute('data-view'), actionNode.getAttribute('data-subtab')); return; } if (action === 'reset-filters') { resetFilters(); return; } if (action === 'refresh') { refresh(); return; } if (action === 'seed-demo') { applyLoadedData(createDemoData()); computeDerivedState(); render(); pushToast('success', 'Demodaten geladen', 'Das Modul arbeitet nun mit lokalen Beispieldaten.'); return; } if (action === 'toggle-realtime') { state.ui.realtimeEnabled = !state.ui.realtimeEnabled; if (state.ui.realtimeEnabled) { startRealtimeRefresh(); } else { stopRealtimeRefresh(); } render(); return; } if (action === 'toggle-compact') { state.ui.compactMode = !state.ui.compactMode; pushToast('info', 'Ansicht', state.ui.compactMode ? 'Kompakte Sicht aktiviert.' : 'Standardansicht aktiviert.'); return; } if (action === 'open-form') { openEntityForm(actionNode.getAttribute('data-entity'), ''); return; } if (action === 'edit-record') { openEntityForm(actionNode.getAttribute('data-entity'), actionNode.getAttribute('data-id')); return; } if (action === 'delete-record') { handleDeleteRecord(actionNode.getAttribute('data-entity'), actionNode.getAttribute('data-id')); return; } if (action === 'open-details') { openRecordDetails(actionNode.getAttribute('data-entity'), actionNode.getAttribute('data-id')); return; } if (action === 'run-calc') { handleRunCalculation(actionNode.getAttribute('data-kind'), actionNode.getAttribute('data-id')); return; } if (action === 'export') { handleExport(actionNode.getAttribute('data-export'), actionNode.getAttribute('data-id')); return; } if (action === 'close-modal' || action === 'close-modal-backdrop') { if (action === 'close-modal-backdrop' && event.target !== actionNode) { return; } closeModal(); return; } if (action === 'submit-modal-form') { const form = qs('.bgz-pv-entity-form'); if (form) { handleEntityFormSubmit(form); } return; } if (action === 'close-drawer' || action === 'close-drawer-backdrop') { if (action === 'close-drawer-backdrop' && event.target !== actionNode) { return; } closeDrawer(); } } function handleRootChange(event) { const filterName = event.target.getAttribute('data-filter'); if (filterName) { if (filterName === 'search') { debouncedSearch(event.target.value); } else { applyFilter(filterName, event.target.value); } } } function handleRootSubmit(event) { const form = event.target.closest('.bgz-pv-entity-form'); if (!form) { return; } event.preventDefault(); handleEntityFormSubmit(form); } function bindEvents() { if (state.refs.bound || !state.root) { return; } const clickHandler = handleRootClick.bind(null); const changeHandler = handleRootChange.bind(null); const submitHandler = handleRootSubmit.bind(null); state.root.addEventListener('click', clickHandler); state.root.addEventListener('change', changeHandler); state.root.addEventListener('input', changeHandler); state.root.addEventListener('submit', submitHandler); state.refs.listeners.push({ type: 'click', handler: clickHandler }); state.refs.listeners.push({ type: 'change', handler: changeHandler }); state.refs.listeners.push({ type: 'input', handler: changeHandler }); state.refs.listeners.push({ type: 'submit', handler: submitHandler }); state.refs.bound = true; } function unbindEvents() { if (!state.root) { return; } state.refs.listeners.forEach(function(listener) { state.root.removeEventListener(listener.type, listener.handler); }); state.refs.listeners = []; state.refs.bound = false; } async function init(container, options) { if (state.initialized) { destroy(); } state.root = resolveRoot(container); state.options = mergeDeep(DEFAULT_OPTIONS, options || {}); injectStyles(); clearErrors(); state.ui.loading = true; state.ui.realtimeEnabled = state.options.enableRealtime !== false; render(); bindEvents(); try { await loadInitialData(); computeDerivedState(); state.ui.loading = false; state.ui.lastRefreshAt = nowTimestamp(); render(); if (state.ui.realtimeEnabled) { startRealtimeRefresh(); } state.initialized = true; pushToast('success', 'Modul bereit', 'Photovoltaik-Modul wurde initialisiert.'); } catch (error) { state.ui.loading = false; render(); pushError(error.message || 'Initialisierung fehlgeschlagen.'); } } function destroy() { stopRealtimeRefresh(); unbindEvents(); if (state.root) { destroyChildren(state.root); } removeStyles(); state.initialized = false; state.root = null; state.cache = { lookup: {}, lastRenderedHtml: '' }; state.ui.modal = { open: false, entity: '', mode: 'create', id: '', title: '', size: 'xl' }; state.ui.drawer = { open: false, title: '', html: '' }; state.ui.toasts = []; } async function refresh(options) { const refreshOptions = options || {}; state.ui.loading = !refreshOptions.silent; render(); try { await loadInitialData(); computeDerivedState(); state.ui.loading = false; state.ui.lastRefreshAt = nowTimestamp(); render(); if (!refreshOptions.silent) { pushToast('success', 'Aktualisiert', 'PV-Daten wurden neu geladen.'); } } catch (error) { state.ui.loading = false; render(); pushError(error.message || 'Aktualisierung fehlgeschlagen.'); } } window.BauGenioPhotovoltaik = { init: init, destroy: destroy, refresh: refresh }; })(); ', '' ].join('\n'); } function generateAndDownloadReport(report) { switch (report.type) { case 'anlagenuebersicht': return exportAnlagenuebersicht(report); case 'fgas-jahresbericht': return exportFgasJahresbericht(report); case 'logbuch': return exportLogbookReport(report); case 'inbetriebnahme': return exportCommissioningPack(report); case 'leistung-energie': return exportEnergyReport(report); case 'excel-export': return exportExcelReport(); default: return exportAnlagenuebersicht(report); } } function createReportHtml(title, bodyHtml) { return [ '', '', '', ' ', ' ' + safeText(title) + '', ' ', ' ', '', '

' + safeText(title) + '

', '
Erstellt am ' + safeText(formatDateTime(nowIso())) + ' · Projekt ' + safeText(state.projectId || 'ohne Projekt-ID') + ' · Filter ' + safeText(serializeFilters() || 'keine') + '
', bodyHtml, ' ', '' ].join('\n'); } function openPrintWindow(html) { var printWindow = window.open('', '_blank', 'noopener,noreferrer,width=1200,height=900'); if (!printWindow) { showToast('Popup blockiert', 'Report konnte nicht im Druckfenster geöffnet werden.'); return; } printWindow.document.open(); printWindow.document.write(html); printWindow.document.close(); printWindow.focus(); } function exportAnlagenuebersicht(report) { var systems = getFilteredSystems(); var dashboard = computeDashboardData(); var body = [ '
', '
Anlagen
' + safeText(dashboard.systemsCount) + '
', '
Plan Kälteleistung
' + safeText(formatNumber(dashboard.performance.planned, 1)) + ' kW
', '
Ist Kälteleistung
' + safeText(formatNumber(dashboard.performance.installed, 1)) + ' kW
', '
', '', '', '', systems.map(function(system) { var compliance = getComplianceForSystem(system.id); return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '' ].join(''); }).join(''), '', '
CodeNameTypStatusStandortKältemittelFüllmengeCO₂ePlan/Ist kWKosten Soll/IstCompliance
' + safeText(system.code) + '' + safeText(system.name) + '' + safeText(getSystemTypeLabel(system.type)) + '' + safeText(getStatusLabel(system.status)) + '' + safeText([system.building, system.floor, system.room, system.section].filter(Boolean).join(' · ')) + '' + safeText(system.refrigerantType || '—') + '' + safeText(formatNumber(system.refrigerantChargeKg, 2)) + ' kg' + safeText(formatNumber(compliance.co2eqTons, 3)) + ' t' + safeText(formatNumber(system.plannedCoolingKw, 1)) + ' / ' + safeText(formatNumber(system.installedCoolingKw, 1)) + '' + safeText(formatCurrency(system.costPlanned)) + ' / ' + safeText(formatCurrency(system.costActual)) + '' + safeText(getComplianceLabel(compliance.compliance)) + '
' ].join('\n'); openPrintWindow(createReportHtml(report && report.title ? report.title : 'Anlagenübersicht', body)); } function exportFgasJahresbericht(report) { var logs = getFilteredRefrigerantLogs(); var systems = getFilteredSystems().filter(function(system) { return system.refrigerantChargeKg > 0; }); var body = [ '
', '
Anlagen mit Kältemittel
' + safeText(systems.length) + '
', '
Gesamtfüllmenge
' + safeText(formatNumber(sumBy(systems, "refrigerantChargeKg"), 2)) + ' kg
', '
Gesamt CO₂e
' + safeText(formatNumber(sumBy(systems, function(system){ return calculateCo2EquivalentTons(system.refrigerantType, system.refrigerantChargeKg); }), 3)) + ' t
', '
', '', '', '', systems.map(function(system) { var compliance = getComplianceForSystem(system.id); return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '' ].join(''); }).join(''), '', '
AnlageKältemittelFüllmenge kgCO₂e tPrüfintervallNächste PrüfungLeckagerate %Compliance
' + safeText(system.code + ' · ' + system.name) + '' + safeText(system.refrigerantType) + '' + safeText(formatNumber(system.refrigerantChargeKg, 2)) + '' + safeText(formatNumber(compliance.co2eqTons, 3)) + '' + safeText(compliance.leakCheckIntervalMonths ? compliance.leakCheckIntervalMonths + " Monate" : "—") + '' + safeText(compliance.nextLeakCheckDate ? formatDate(compliance.nextLeakCheckDate) : "—") + '' + safeText(formatNumber(calculateLeakRatePercent(system.id), 2)) + '' + safeText(getComplianceLabel(compliance.compliance)) + '
', '

Kältemittel-Logbuch

', '', '', '', logs.map(function(log) { var system = getSystemById(log.systemId); return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '' ].join(''); }).join(''), '', '
DatumAnlageVorgangKältemittelMenge kgAusführenderZertifikat
' + safeText(formatDate(log.date)) + '' + safeText(system ? system.code + ' · ' + system.name : '') + '' + safeText(getLogbookActionLabel(log.action)) + '' + safeText(log.refrigerantType) + '' + safeText(formatNumber(log.amountKg, 2)) + '' + safeText(log.operatorName) + '' + safeText(log.certificateNo) + '
' ].join('\n'); openPrintWindow(createReportHtml(report && report.title ? report.title : 'F-Gas-Jahresbericht', body)); } function exportLogbookReport(report) { var logs = getFilteredRefrigerantLogs(); var body = [ '', '', '', logs.map(function(log) { var system = getSystemById(log.systemId); return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', '' ].join(''); }).join(''), '', '
DatumAnlageVorgangKältemittelMenge kgAusführenderUnternehmenZertifikatBemerkung
' + safeText(formatDate(log.date)) + '' + safeText(system ? system.code + ' · ' + system.name : '') + '' + safeText(getLogbookActionLabel(log.action)) + '' + safeText(log.refrigerantType) + '' + safeText(formatNumber(log.amountKg, 2)) + '' + safeText(log.operatorName) + '' + safeText(log.company) + '' + safeText(log.certificateNo) + '' + safeText(log.notes) + '
' ].join('\n'); openPrintWindow(createReportHtml(report && report.title ? report.title : 'Kältemittel-Logbuch', body)); } function exportCommissioningPack(report) { var tests = getFilteredPressureTests(); var commissioning = getFilteredCommissioningRecords(); var body = [ '

Druckproben

', '', tests.map(function(test) { var system = getSystemById(test.systemId); return ''; }).join(''), '
DatumAnlageMediumDruckHaltezeitErgebnisPrüferBemerkung
' + safeText(formatDate(test.date)) + '' + safeText(system ? system.code + ' · ' + system.name : '') + '' + safeText(test.testMedium) + '' + safeText(formatNumber(test.testPressureBar, 1)) + ' bar' + safeText(formatNumber(test.holdTimeMin, 0)) + ' min' + safeText(getTestResultLabel(test.result)) + '' + safeText(test.inspector) + '' + safeText(test.notes) + '
', '

Inbetriebnahmen

', '', commissioning.map(function(record) { var system = getSystemById(record.systemId); return ''; }).join(''), '
DatumAnlageVakuumFüllungÜberhitzungUnterkühlungDrückeLeistungErgebnis
' + safeText(formatDate(record.date)) + '' + safeText(system ? system.code + ' · ' + system.name : '') + '' + safeText(formatNumber(record.vacuumMicrons, 0)) + ' µm' + safeText(formatNumber(record.fillAmountKg, 2)) + ' kg' + safeText(formatNumber(record.superheatK, 1)) + ' K' + safeText(formatNumber(record.subcoolingK, 1)) + ' K' + safeText(formatNumber(record.suctionPressureBar, 1)) + ' / ' + safeText(formatNumber(record.dischargePressureBar, 1)) + ' bar' + safeText(formatNumber(record.measuredPerformanceKw, 1)) + ' kW' + safeText(record.result) + '
' ].join('\n'); openPrintWindow(createReportHtml(report && report.title ? report.title : 'Inbetriebnahme-Paket', body)); } function exportEnergyReport(report) { var systems = getFilteredSystems(); var body = [ '', '', '', systems.map(function(system) { var deviation = system.plannedCoolingKw ? round(((system.installedCoolingKw - system.plannedCoolingKw) / system.plannedCoolingKw) * 100, 2) : 0; return ''; }).join(''), '', '
AnlageEERCOPSEERPlan kWIst kWElektrik kWAbweichung
' + safeText(system.code + ' · ' + system.name) + '' + safeText(formatNumber(system.eer, 2)) + '' + safeText(formatNumber(system.cop, 2)) + '' + safeText(formatNumber(system.seer, 2)) + '' + safeText(formatNumber(system.plannedCoolingKw, 1)) + '' + safeText(formatNumber(system.installedCoolingKw, 1)) + '' + safeText(formatNumber(system.electricalPowerKw, 1)) + '' + safeText(formatPercent(deviation, 2)) + '
' ].join('\n'); openPrintWindow(createReportHtml(report && report.title ? report.title : 'Leistungs- und Energiebericht', body)); } function exportExcelReport() { downloadTextFile('baugenio-kaeltetechnik-export.xls', buildExcelHtml(), 'application/vnd.ms-excel;charset=utf-8'); } function downloadReportById(id) { var report = getReportById(id); if (!report) { showToast('Report nicht gefunden', 'Der gewählte Bericht existiert nicht mehr.'); return; } generateAndDownloadReport(report); } function exportSystemsCsv() { downloadTextFile('anlagen-kaeltetechnik.csv', buildSystemsCsv(), 'text/csv;charset=utf-8'); showToast('CSV exportiert', 'Anlagenübersicht wurde als CSV heruntergeladen.'); } function exportNetworkCsv() { downloadTextFile('netzplanung-kaeltetechnik.csv', buildNetworkCsv(), 'text/csv;charset=utf-8'); showToast('CSV exportiert', 'Netzplanung wurde als CSV heruntergeladen.'); } function exportMaintenanceCsv() { downloadTextFile('wartung-kaeltetechnik.csv', buildMaintenanceCsv(), 'text/csv;charset=utf-8'); showToast('CSV exportiert', 'Wartungsdaten wurden als CSV heruntergeladen.'); } function exportFgasReportQuick() { var report = normalizeReport({ type: 'fgas-jahresbericht', title: 'F-Gas-Jahresbericht ' + new Date().getFullYear(), format: 'PDF', createdAt: nowIso(), createdBy: state.options.currentUser || 'System' }); state.data.reports.unshift(report); exportFgasJahresbericht(report); rerender(); } function exportLogbookQuick() { var report = normalizeReport({ type: 'logbuch', title: 'Kältemittel-Logbuch', format: 'PDF', createdAt: nowIso(), createdBy: state.options.currentUser || 'System' }); state.data.reports.unshift(report); exportLogbookReport(report); rerender(); } function exportAnlagenuebersichtQuick() { var report = normalizeReport({ type: 'anlagenuebersicht', title: 'Anlagenübersicht', format: 'PDF', createdAt: nowIso(), createdBy: state.options.currentUser || 'System' }); state.data.reports.unshift(report); exportAnlagenuebersicht(report); rerender(); } function exportCommissioningPackQuick() { var report = normalizeReport({ type: 'inbetriebnahme', title: 'Inbetriebnahme-Paket', format: 'PDF', createdAt: nowIso(), createdBy: state.options.currentUser || 'System' }); state.data.reports.unshift(report); exportCommissioningPack(report); rerender(); } function exportEnergyReportQuick() { var report = normalizeReport({ type: 'leistung-energie', title: 'Leistungs- und Energiebericht', format: 'PDF', createdAt: nowIso(), createdBy: state.options.currentUser || 'System' }); state.data.reports.unshift(report); exportEnergyReport(report); rerender(); } async function submitModalForm() { var backdrop = query('[data-role="modal-backdrop"]'); if (!backdrop) { return; } var form = backdrop.querySelector('form[data-form]'); if (!form) { return; } if (!form.reportValidity()) { return; } var formType = form.getAttribute('data-form'); var formData = toFormObject(form); switch (formType) { case 'system': return saveSystemFromForm(formData); case 'filters': updateFilters(formData); closeModal(); showToast('Filter angewendet', 'Die Auswertung wurde aktualisiert.'); return; case 'logbook': return saveLogbookEntryFromForm(formData); case 'certificate': return saveCertificateFromForm(formData); case 'network-segment': return saveNetworkSegmentFromForm(formData); case 'pressure-test': return savePressureTestFromForm(formData); case 'commissioning': return saveCommissioningFromForm(formData); case 'maintenance': return saveMaintenanceFromForm(formData); case 'spare-part': return saveSparePartFromForm(formData); case 'report': return saveReportFromForm(formData); default: return; } } function handleClick(event) { var actionNode = event.target.closest('[data-action]'); if (!actionNode || !state.root.contains(actionNode)) { return; } var action = actionNode.getAttribute('data-action'); var id = actionNode.getAttribute('data-id'); var systemId = actionNode.getAttribute('data-system-id'); var entity = actionNode.getAttribute('data-entity'); var page = actionNode.getAttribute('data-page'); var tab = actionNode.getAttribute('data-tab'); switch (action) { case 'switch-tab': setActiveTab(tab); return; case 'refresh': refresh(); return; case 'open-filter-panel': openModal('filters'); return; case 'reset-filters': resetFilters(); return; case 'open-system-modal': openModal('system', {}); return; case 'edit-system': openModal('system', { id: id }); return; case 'open-system-details': selectSystem(id); setActiveTab('anlagen'); return; case 'delete-system': if (window.confirm('Anlage wirklich löschen?')) { deleteEntity(state.data.systems, id, 'systems', 'Anlage wurde gelöscht.'); } return; case 'open-logbook-modal': openModal('logbook', { systemId: systemId || state.ui.selectedSystemId || '', id: id || '' }); return; case 'edit-logbook-entry': openModal('logbook', { id: id }); return; case 'delete-logbook-entry': if (window.confirm('Logbucheintrag wirklich löschen?')) { deleteEntity(state.data.refrigerantLogs, id, 'refrigerants/logbook', 'Logbucheintrag gelöscht.'); } return; case 'open-certificate-modal': openModal('certificate', {}); return; case 'edit-certificate': openModal('certificate', { id: id }); return; case 'delete-certificate': if (window.confirm('Zertifikat wirklich löschen?')) { deleteEntity(state.data.certificates, id, 'refrigerants/certificates', 'Zertifikat gelöscht.'); } return; case 'open-network-segment-modal': openModal('network-segment', {}); return; case 'edit-network-segment': openModal('network-segment', { id: id }); return; case 'delete-network-segment': if (window.confirm('Netzsegment wirklich löschen?')) { deleteEntity(state.data.networkSegments, id, 'network', 'Netzsegment gelöscht.'); } return; case 'open-pressure-test-modal': openModal('pressure-test', { systemId: systemId || state.ui.selectedSystemId || '', id: id || '' }); return; case 'edit-pressure-test': openModal('pressure-test', { id: id }); return; case 'delete-pressure-test': if (window.confirm('Druckprobe wirklich löschen?')) { deleteEntity(state.data.pressureTests, id, 'commissioning/pressure-tests', 'Druckprobe gelöscht.'); } return; case 'open-commissioning-modal': openModal('commissioning', { systemId: systemId || state.ui.selectedSystemId || '', id: id || '' }); return; case 'edit-commissioning': openModal('commissioning', { id: id }); return; case 'delete-commissioning': if (window.confirm('Inbetriebnahmeprotokoll wirklich löschen?')) { deleteEntity(state.data.commissioningRecords, id, 'commissioning/records', 'Inbetriebnahmeprotokoll gelöscht.'); } return; case 'open-maintenance-task-modal': openModal('maintenance', { systemId: systemId || state.ui.selectedSystemId || '', id: id || '' }); return; case 'edit-maintenance-task': openModal('maintenance', { id: id }); return; case 'complete-maintenance-task': completeMaintenanceTask(id); return; case 'delete-maintenance-task': if (window.confirm('Wartungsaufgabe wirklich löschen?')) { deleteEntity(state.data.maintenanceTasks, id, 'maintenance', 'Wartungsaufgabe gelöscht.'); } return; case 'open-spare-part-modal': openModal('spare-part', {}); return; case 'edit-spare-part': openModal('spare-part', { id: id }); return; case 'delete-spare-part': if (window.confirm('Ersatzteil wirklich löschen?')) { deleteEntity(state.data.spareParts, id, 'maintenance/spare-parts', 'Ersatzteil gelöscht.'); } return; case 'open-report-modal': openModal('report', {}); return; case 'download-report': downloadReportById(id); return; case 'delete-report': if (window.confirm('Reporteintrag wirklich löschen?')) { deleteEntity(state.data.reports, id, 'reports', 'Reporteintrag gelöscht.'); } return; case 'export-systems-csv': exportSystemsCsv(); return; case 'export-network-csv': exportNetworkCsv(); return; case 'export-maintenance-csv': exportMaintenanceCsv(); return; case 'export-anlagenuebersicht': exportAnlagenuebersichtQuick(); return; case 'export-fgas-report': exportFgasReportQuick(); return; case 'export-logbook': exportLogbookQuick(); return; case 'export-commissioning-pack': exportCommissioningPackQuick(); return; case 'export-energy-report': exportEnergyReportQuick(); return; case 'export-excel': exportExcelReport(); return; case 'change-page': changePage(entity, page); return; case 'submit-modal-form': submitModalForm(); return; case 'close-modal': closeModal(); return; default: return; } } var debouncedSearchHandler = debounce(function(event) { setSearchText(event.target.value || ''); }, 180); function handleInput(event) { var target = event.target; if (!target || !state.root.contains(target)) { return; } if (target.matches('[data-role="global-search"]')) { debouncedSearchHandler(event); return; } if (target.matches('form[data-form] input, form[data-form] select, form[data-form] textarea')) { setUnsavedChanges(true); } } function handleChange(event) { var target = event.target; if (!target || !state.root.contains(target)) { return; } if (target.matches('[data-role="quick-type-filter"]')) { updateFilters({ type: target.value || '' }); return; } if (target.matches('[data-role="quick-status-filter"]')) { updateFilters({ status: target.value || '' }); return; } } function handleKeydown(event) { if (event.key === 'Escape' && state.ui.modalOpen) { closeModal(); return; } if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { if (state.ui.modalOpen) { event.preventDefault(); submitModalForm(); } } } function bindEvents() { if (!state.root) { return; } state.root.addEventListener('click', handleClick); state.root.addEventListener('input', handleInput); state.root.addEventListener('change', handleChange); document.addEventListener('keydown', handleKeydown); } function unbindEvents() { if (!state.root) { return; } state.root.removeEventListener('click', handleClick); state.root.removeEventListener('input', handleInput); state.root.removeEventListener('change', handleChange); document.removeEventListener('keydown', handleKeydown); } async function init(options) { if (state.mounted) { return refresh(); } state.options = mergeObjects({ root: null, projectId: '', currentUser: 'System' }, options || {}); state.root = resolveRoot(state.options.root); if (!state.root) { throw new Error(MODULE_NAME + ': Root-Element nicht gefunden.'); } state.projectId = state.options.projectId || state.root.getAttribute('data-project-id') || ''; state.initializedAt = nowIso(); restoreUiState(); injectStyles(); bindEvents(); setUnsavedChanges(false); state.mounted = true; state.destroyed = false; renderApp(); await loadAllData(); if (!state.ui.selectedSystemId && state.data.systems.length) { state.ui.selectedSystemId = state.data.systems[0].id; } rerender(); logActivity('Modul initialisiert', 'Projekt ' + (state.projectId || 'ohne Projekt-ID')); showToast('Modul bereit', 'Kältetechnik & Klimaanlagen wurden geladen.'); } async function refresh() { if (!state.root) { return; } clearCache(); await loadAllData(); rerender(); showToast('Aktualisiert', 'Datenbestand wurde neu geladen.'); } function destroy() { if (!state.mounted || state.destroyed) { return; } unbindEvents(); clearTimeout(state.ui.toastTimer); removeStyles(); if (state.root) { state.root.innerHTML = ''; state.root.removeAttribute(ROOT_ATTR); } state.mounted = false; state.destroyed = true; state.root = null; state.ui.modalOpen = null; state.ui.modalPayload = null; state.ui.selectedSystemId = null; } window.BauGenioKaeltetechnik = { init: init, destroy: destroy, refresh: refresh }; })(); '; } function openReportWindow(html, filename) { const reportWindow = window.open('', '_blank'); if (reportWindow) { reportWindow.document.open(); reportWindow.document.write(html); reportWindow.document.close(); reportWindow.focus(); window.setTimeout(function() { try { reportWindow.print(); } catch (error) { console.warn(error); } }, 400); registerReportJob(filename.replace(/_/g, ' '), filename + '.pdf'); pushNotification('Bericht generiert. Druckdialog geöffnet.', 'success'); } else { downloadBlob(filename + '.html', html, 'text/html;charset=utf-8'); registerReportJob(filename.replace(/_/g, ' '), filename + '.html'); pushNotification('Popup blockiert – HTML-Bericht heruntergeladen.', 'warning'); } } function generateReport(reportType) { const dashboard = computeDashboardData(); const latestMeasurements = getLatestMeasurementsByStation(); let title = ''; let sections = []; if (reportType === 'herstellungsprotokoll') { title = 'Herstellungsprotokoll-Sammlung Spezialtiefbau'; const rows = filterProtocols(state.data.protocols).map(function(protocol) { const element = findElementById(protocol.elementId); return [ protocol.elementNumber || (element ? element.elementNumber : ''), getLookupLabel(PROTOCOL_TYPES, protocol.type), element ? element.section : '', element ? element.axis : '', formatDateTime(protocol.executedAt), protocol.operator, protocol.device, protocol.notes ]; }); sections = [ '

Übersicht

Enthält alle aktuell gefilterten Herstellungsprotokolle pro Verbauabschnitt.
', buildPrintTable(['Element', 'Protokoll', 'Abschnitt', 'Achse', 'Zeit', 'Bediener', 'Gerät', 'Notizen'], rows) ]; } else if (reportType === 'messtechnikbericht') { title = 'Messtechnischer Bericht Spezialtiefbau'; sections = [ '

Status der Messstellen

' + buildPrintTable( ['Messstelle', 'Typ', 'Abschnitt', 'Achse', 'Status', 'Wert', 'Warnwert', 'Eingriffswert', 'Messzeit'], latestMeasurements.map(function(item) { return [ item.stationName, getLookupLabel(MEASUREMENT_TYPES, item.type), item.section, item.axis, getLookupLabel(MEASUREMENT_STATUSES, item.status), formatNumber(item.value, 2, item.unit), formatNumber(item.warnThreshold, 2, item.unit), formatNumber(item.alarmThreshold, 2, item.unit), formatDateTime(item.measuredAt) ]; }) ), '

Bewertung

' + (getCriticalMeasurements().length ? 'Es liegen Messstellen im Warn- oder Alarmbereich vor. Maßnahmen: Messintervall verdichten, örtliche Zustandsbeurteilung, Aushub- und Lastzustand prüfen.' : 'Zum Berichtszeitpunkt liegen keine Messstellen im Warn- oder Alarmbereich vor.') + '
' ]; } else if (reportType === 'ankerpruefbericht') { title = 'Ankerprüfbericht nach DIN EN 1537'; const tests = flattenAnchorTests(); sections = [ '

Prüfübersicht

' + buildPrintTable( ['Anker', 'Prüfart', 'Status', 'Ausführung', 'Prüflast', 'Setzwert', 'Kriechmaß', 'Freigabe'], tests.map(function(test) { return [ test.anchorNumber, getLookupLabel(ANCHOR_TEST_TYPES, test.type), getLookupLabel(TEST_STATUSES, test.status), formatDateTime(test.executedAt), formatNumber(test.maxLoad, 0, 'kN'), formatNumber(test.setValue, 2, 'mm'), formatNumber(test.creepMeasure, 2, 'mm'), test.approvedBy || 'offen' ]; }) ), '

Statistik

Anzahl Prüfungen: ' + escapeHtml(String(tests.length)) + ', bestanden: ' + escapeHtml(String(getAnchorStatistics().passed)) + ', offen: ' + escapeHtml(String(getAnchorStatistics().open)) + '.
' ]; } else if (reportType === 'betonage-gesamtprotokoll') { title = 'Betonage-Gesamtprotokoll'; const betonageProtocols = state.data.protocols.filter(function(protocol) { return protocol.type === 'betonageprotokoll'; }); sections = [ '

Gesamtübersicht

' + buildPrintTable( ['Element', 'Betonsorte', 'Menge', 'Ausbreitmaß', 'Kontraktorprüfung', 'Zeit', 'Notizen'], betonageProtocols.map(function(protocol) { return [ protocol.elementNumber, protocol.concreteGrade, formatNumber(protocol.concreteVolume, 1, 'm³'), formatNumber(protocol.slumpSpread, 0, 'mm'), boolText(protocol.contractorCheck), formatDateTime(protocol.executedAt), protocol.notes ]; }) ), '

Mengenstatus

Soll: ' + escapeHtml(formatNumber(dashboard.concrete.planned, 1, 'm³')) + ', Ist: ' + escapeHtml(formatNumber(dashboard.concrete.actual, 1, 'm³')) + ', Abweichung: ' + escapeHtml(formatNumber(dashboard.concrete.variance, 1, 'm³')) + '.
' ]; } else if (reportType === 'wochenbericht' || reportType === 'monatsbericht') { title = reportType === 'wochenbericht' ? 'Wochenbericht Spezialtiefbau' : 'Monatsbericht Spezialtiefbau'; sections = [ '

Management-Snapshot

' + buildPrintTable( ['Kennzahl', 'Wert'], [ ['Herstellungsfortschritt', dashboard.finishedElements + ' / ' + dashboard.totalElements + ' (' + formatPercent(dashboard.finishedPercent) + ')'], ['Betonverbrauch Ist', formatNumber(dashboard.concrete.actual, 1, 'm³')], ['Kosten Ist', formatCurrency(dashboard.costs.actual)], ['Offene Ankerprüfungen', String(dashboard.openAnchorTests.length)], ['Kritische Messpunkte', String(dashboard.criticalMeasurements.length)] ] ), '

Wochenleistung

' + buildPrintTable( ['Leistungswert', 'Menge'], [ ['Bohrpfähle', formatNumber(dashboard.weekly.bohrpfahlLength, 1, 'lfm')], ['Schlitzwand', formatNumber(dashboard.weekly.schlitzwandArea, 1, 'm²')], ['Anker', formatNumber(dashboard.weekly.anchorCount, 0, 'Stk')] ] ), '

Risikofelder

' + (dashboard.criticalMeasurements.length ? 'Monitoring: ' + dashboard.criticalMeasurements.map(function(item) { return item.stationName + ' (' + getLookupLabel(MEASUREMENT_STATUSES, item.status) + ')'; }).join(', ') + '.' : 'Kein kritischer Monitoringstatus.') + (dashboard.openAnchorTests.length ? ' Offene Prüfungen: ' + dashboard.openAnchorTests.map(function(item) { return item.anchorNumber; }).join(', ') + '.' : ' Keine offenen Ankerprüfungen.') + '
' ]; } else if (reportType === 'baugrubenbericht') { title = 'Baugrubenbericht'; sections = [ '

Phasenübersicht

' + buildPrintTable( ['Abschnitt', 'Phase', 'Status', 'Tiefe', 'Verbau', 'Aussteifung', 'Zustandsbeurteilung'], state.data.excavations.map(function(item) { return [ item.section, item.phase, getLookupLabel(EXCAVATION_PHASE_STATUSES, item.status), formatNumber(item.depth, 1, 'm'), item.supportType, safeArrayText(item.bracingLevels), item.conditionAssessment ]; }) ) ]; } else { title = 'Spezialtiefbau-Bericht'; sections = ['
Kein Berichtstyp definiert.
']; } const filename = slugify(title) + '_' + toIsoDate(new Date()); const html = buildReportDocument(title, sections); openReportWindow(html, filename); } const boundHandlers = { click: null, submit: null, input: null, change: null, keydown: null }; function setPagination(scope, page) { if (!state.ui.pagination[scope]) { return; } state.ui.pagination[scope].page = Math.max(1, toNumber(page, 1)); } function resetAllPagination() { Object.keys(state.ui.pagination).forEach(function(scope) { state.ui.pagination[scope].page = 1; }); } function applyFilterField(name, value) { if (state.filters[name] === undefined) { return; } state.filters[name] = value; resetAllPagination(); renderApp(); } function clearFilters() { state.filters.search = ''; state.filters.elementType = ''; state.filters.manufacturingStatus = ''; state.filters.protocolType = ''; state.filters.testStatus = ''; state.filters.measurementStatus = ''; state.filters.dateFrom = ''; state.filters.dateTo = ''; state.filters.section = ''; state.filters.axis = ''; resetAllPagination(); renderApp(); } async function handleAction(action, trigger, event) { if (!action) { return; } if (action === 'close-modal') { stopEvent(event); closeModal(); return; } if (action === 'modal-backdrop' && event.target.classList.contains('bgz-modal')) { closeModal(); return; } if (action === 'dismiss-notification') { removeNotification(trigger.getAttribute('data-id')); return; } if (action === 'switch-view') { setCurrentView(trigger.getAttribute('data-view')); renderApp(); return; } if (action === 'refresh') { await refresh(); return; } if (action === 'reset-filters') { clearFilters(); return; } if (action === 'sort') { const scope = trigger.getAttribute('data-scope'); const key = trigger.getAttribute('data-key'); const dir = trigger.getAttribute('data-dir'); if (state.ui.sort[scope]) { state.ui.sort[scope] = { key: key, dir: dir }; renderApp(); } return; } if (action === 'paginate') { setPagination(trigger.getAttribute('data-scope'), trigger.getAttribute('data-page')); renderApp(); return; } if (action === 'select-element') { state.ui.selectedElementId = trigger.getAttribute('data-id'); renderApp(); return; } if (action === 'select-protocol') { state.ui.selectedProtocolId = trigger.getAttribute('data-id'); renderApp(); return; } if (action === 'select-station') { state.ui.selectedStationId = trigger.getAttribute('data-station-id'); renderApp(); return; } if (action === 'select-anchor') { state.ui.selectedAnchorId = trigger.getAttribute('data-id'); renderApp(); return; } if (action === 'select-excavation') { state.ui.selectedExcavationId = trigger.getAttribute('data-id'); renderApp(); return; } if (action === 'open-modal') { const modalType = trigger.getAttribute('data-modal-type'); const mode = trigger.getAttribute('data-mode') || 'create'; let payload = {}; if (modalType === 'protocol' && trigger.getAttribute('data-element-id')) { const element = findElementById(trigger.getAttribute('data-element-id')); payload = { elementId: element ? element.id : '', elementNumber: element ? element.elementNumber : '' }; } if (modalType === 'anchor-test' && trigger.getAttribute('data-anchor-id')) { payload = { anchorId: trigger.getAttribute('data-anchor-id') }; } if (modalType === 'visit' && trigger.getAttribute('data-section')) { payload = { section: trigger.getAttribute('data-section') }; } openModal(modalType, mode, payload); return; } if (action === 'edit-element') { openModal('element', 'edit', findElementById(trigger.getAttribute('data-id'))); return; } if (action === 'duplicate-element') { await duplicateElement(trigger.getAttribute('data-id')); return; } if (action === 'delete-element') { if (window.confirm('Bauelement wirklich löschen?')) { await removeElement(trigger.getAttribute('data-id')); } return; } if (action === 'edit-protocol') { openModal('protocol', 'edit', findProtocolById(trigger.getAttribute('data-id'))); return; } if (action === 'duplicate-protocol') { await duplicateProtocol(trigger.getAttribute('data-id')); return; } if (action === 'delete-protocol') { if (window.confirm('Protokoll wirklich löschen?')) { await removeProtocol(trigger.getAttribute('data-id')); } return; } if (action === 'edit-measurement') { openModal('measurement', 'edit', findMeasurementById(trigger.getAttribute('data-id'))); return; } if (action === 'delete-measurement') { if (window.confirm('Messung wirklich löschen?')) { await removeMeasurement(trigger.getAttribute('data-id')); } return; } if (action === 'edit-anchor') { openModal('anchor', 'edit', findAnchorById(trigger.getAttribute('data-id'))); return; } if (action === 'delete-anchor') { if (window.confirm('Anker wirklich löschen?')) { await removeAnchor(trigger.getAttribute('data-id')); } return; } if (action === 'edit-excavation') { openModal('excavation', 'edit', findExcavationById(trigger.getAttribute('data-id'))); return; } if (action === 'delete-excavation') { if (window.confirm('Baugrubenphase wirklich löschen?')) { await removeExcavation(trigger.getAttribute('data-id')); } return; } if (action === 'export-excel') { exportExcelWorkbook(); return; } if (action === 'export-elements-csv') { exportCollectionCsv('elements'); return; } if (action === 'export-protocols-csv') { exportCollectionCsv('protocols'); return; } if (action === 'export-measurements-csv') { exportCollectionCsv('measurements'); return; } if (action === 'export-anchors-csv') { exportCollectionCsv('anchors'); return; } if (action === 'export-excavation-csv') { exportCollectionCsv('excavation'); return; } if (action === 'generate-report') { generateReport(trigger.getAttribute('data-report-type')); return; } } function handleRootClick(event) { const trigger = event.target.closest('[data-action]'); if (!trigger || !getRoot().contains(trigger)) { return; } handleAction(trigger.getAttribute('data-action'), trigger, event).catch(function(error) { console.error(error); pushNotification(error.message || 'Aktion fehlgeschlagen.', 'danger', 8000); }); } function handleRootInput(event) { const target = event.target; const action = target.getAttribute('data-action'); if (action === 'filter-input' && target.name) { applyFilterField(target.name, target.value); return; } } function handleRootChange(event) { const target = event.target; const action = target.getAttribute('data-action'); if (action === 'filter-change' && target.name) { applyFilterField(target.name, target.value); return; } if (target.getAttribute('data-role') === 'protocol-type') { syncProtocolFormVisibility(); return; } if (target.getAttribute('data-role') === 'measurement-type') { syncMeasurementFormVisibility(); return; } } async function handleRootSubmit(event) { const form = event.target; const formType = form.getAttribute('data-form-type'); if (!formType) { return; } stopEvent(event); try { const data = serializeForm(form); if (formType === 'element') { await saveElement(data); } else if (formType === 'protocol') { await saveProtocol(data); } else if (formType === 'measurement') { await saveMeasurement(data); } else if (formType === 'anchor') { await saveAnchor(data); } else if (formType === 'anchor-test') { await saveAnchorTest(data); } else if (formType === 'excavation') { await saveExcavation(data); } else if (formType === 'visit') { await saveVisit(data); } } catch (error) { console.error(error); pushNotification(error.message || 'Speichern fehlgeschlagen.', 'danger', 8000); } } function handleKeydown(event) { if (event.key === 'Escape' && state.ui.modal.open) { closeModal(); } } function bindEvents() { const root = getRoot(); if (!root) { return; } boundHandlers.click = handleRootClick; boundHandlers.input = debounce(handleRootInput, 120); boundHandlers.change = handleRootChange; boundHandlers.submit = handleRootSubmit; boundHandlers.keydown = handleKeydown; root.addEventListener('click', boundHandlers.click); root.addEventListener('input', boundHandlers.input); root.addEventListener('change', boundHandlers.change); root.addEventListener('submit', boundHandlers.submit); document.addEventListener('keydown', boundHandlers.keydown); } function unbindEvents() { const root = getRoot(); if (!root) { return; } if (boundHandlers.click) { root.removeEventListener('click', boundHandlers.click); } if (boundHandlers.input) { root.removeEventListener('input', boundHandlers.input); } if (boundHandlers.change) { root.removeEventListener('change', boundHandlers.change); } if (boundHandlers.submit) { root.removeEventListener('submit', boundHandlers.submit); } if (boundHandlers.keydown) { document.removeEventListener('keydown', boundHandlers.keydown); } boundHandlers.click = null; boundHandlers.input = null; boundHandlers.change = null; boundHandlers.submit = null; boundHandlers.keydown = null; } function startAutoRefresh() { stopAutoRefresh(); if (!state.options.autoRefresh) { return; } autoRefreshTimer = window.setInterval(function() { refresh().catch(function(error) { console.error(error); }); }, Math.max(10000, toNumber(state.options.autoRefreshIntervalMs, 60000))); } function stopAutoRefresh() { if (autoRefreshTimer) { window.clearInterval(autoRefreshTimer); autoRefreshTimer = null; } } function mergeOptions(options) { state.options = shallowMerge(DEFAULT_OPTIONS, options || {}); } function init(container, options) { const root = resolveContainer(container); if (!root) { throw new Error('Container für ' + MODULE_NAME + ' nicht gefunden.'); } if (state.initialized) { destroy(); } state = createInitialState(); state.container = root; mergeOptions(options || {}); setCurrentView(state.options.initialView || 'dashboard'); injectStyles(); renderApp(); bindEvents(); startAutoRefresh(); state.initialized = true; loadInitialData(); } function destroy() { stopAutoRefresh(); unbindEvents(); const root = getRoot(); if (root) { root.innerHTML = ''; root.classList.remove(ROOT_CLASS); } removeStyles(); state = createInitialState(); } window.BauGenioSchachtbau = { init: init, destroy: destroy, refresh: refresh }; })(); function normalizeMeasurement(item) { const record = shallowMerge({ id: uid('measurement'), stationId: uid('station'), stationName: '', type: 'inklinometer', section: '', axis: '', measuredAt: new Date().toISOString(), value: 0, unit: 'mm', warnThreshold: 0, alarmThreshold: 0, trend: 0, timeseries: [], profile: [], groundwaterLevel: null, comment: '', operator: '', status: 'normal' }, item || {}); record.value = toNumber(record.value, 0); record.warnThreshold = toNumber(record.warnThreshold, 0); record.alarmThreshold = toNumber(record.alarmThreshold, 0); record.trend = toNumber(record.trend, 0); record.timeseries = ensureArray(record.timeseries); record.profile = ensureArray(record.profile); record.groundwaterLevel = record.groundwaterLevel === null ? null : toNumber(record.groundwaterLevel, 0); record.status = computeMeasurementStatus(record); return record; } function normalizeMeasurement(item) { const record = shallowMerge({ id: uid('measurement'), stationId: uid('station'), stationName: '', type: 'inklinometer', section: '', axis: '', measuredAt: new Date().toISOString(), value: 0, unit: 'mm', warnThreshold: 0, alarmThreshold: 0, trend: 0, timeseries: [], profile: [], groundwaterLevel: null, comment: '', operator: '', status: 'normal' }, item || {}); record.value = toNumber(record.value, 0); record.warnThreshold = toNumber(record.warnThreshold, 0); record.alarmThreshold = toNumber(record.alarmThreshold, 0); record.trend = toNumber(record.trend, 0); record.timeseries = ensureArray(record.timeseries); record.profile = ensureArray(record.profile); record.groundwaterLevel = record.groundwaterLevel === null ? null : toNumber(record.groundwaterLevel, 0); record.status = item && item.status ? item.status : computeMeasurementStatus(record); return record; } async function removeMeasurement(id) { await deleteRecord('/measurements', 'measurements', id); if (findMeasurementById(id) && state.ui.selectedStationId === findMeasurementById(id).stationId) { state.ui.selectedStationId = null; } pushNotification('Messung gelöscht.', 'success'); renderApp(); } async function removeMeasurement(id) { const existing = findMeasurementById(id); await deleteRecord('/measurements', 'measurements', id); if (existing && state.ui.selectedStationId === existing.stationId) { state.ui.selectedStationId = null; } pushNotification('Messung gelöscht.', 'success'); renderApp(); } async function removeMeasurement(id) { await deleteRecord('/measurements', 'measurements', id); if (findMeasurementById(id) && state.ui.selectedStationId === findMeasurementById(id).stationId) { state.ui.selectedStationId = null; } pushNotification('Messung gelöscht.', 'success'); renderApp(); } async function removeMeasurement(id) { await deleteRecord('/measurements', 'measurements', id); if (findMeasurementById(id) && state.ui.selectedStationId === findMeasurementById(id).stationId) { state.ui.selectedStationId = null; } pushNotification('Messung gelöscht.', 'success'); renderApp(); } async function removeMeasurement(id) { await deleteRecord('/measurements', 'measurements', id); if (findMeasurementById(id) && state.ui.selectedStationId === findMeasurementById(id).stationId) { state.ui.selectedStationId = null; } pushNotification('Messung gelöscht.', 'success'); renderApp(); } ', '' ].join('')); popup.document.close(); popup.focus(); } function normalizeBootstrapPayload(payload) { var data = payload || {}; state.data.dashboard = data.dashboard || null; state.data.zones = safeArray(data.zones).map(normalizeZone); state.data.serviceCatalog = safeArray(data.serviceCatalog).map(normalizeService); state.data.checklistTemplates = safeArray(data.checklistTemplates).map(normalizeChecklist); state.data.inspections = safeArray(data.inspections).map(normalizeInspection); state.data.defects = safeArray(data.defects).map(normalizeDefect); state.data.providers = safeArray(data.providers).map(normalizeProvider); state.data.contracts = safeArray(data.contracts); state.data.assignments = safeArray(data.assignments).map(normalizeAssignment); state.data.timesheets = safeArray(data.timesheets).map(normalizeTimesheet); state.data.complaints = safeArray(data.complaints).map(normalizeComplaint); state.data.invoices = safeArray(data.invoices).map(normalizeInvoice); state.data.materials = safeArray(data.materials).map(normalizeMaterial); state.data.stockMovements = safeArray(data.stockMovements).map(normalizeMovement); state.data.machines = safeArray(data.machines).map(normalizeMachine); state.data.reports = safeArray(data.reports); } function createSeedData() { return { dashboard: null, zones: [ { id: 'zone_a_eg_empfang', code: 'A-EG-001', building: 'Haus A', buildingPart: 'Empfang', floor: 'EG', room: 'Empfang', title: 'Empfang / Lobby', usageType: 'buero', phase: 'sicht', status: 'gereinigt', acceptanceStatus: 'bestanden', providerId: 'prov_glanzwerk', length: 8.5, width: 6.2, height: 3.2, manualWindowArea: 14, manualFacadeArea: 0, cleanedArea: 137.54, surfaces: ['boden_fliesen', 'wand_standard', 'decke_standard', 'fenster'], dirtGrade: 'mittel', specialRequirements: [], accessRequirements: ['schluessel'], dueDate: '2026-04-08', notes: 'Hohe Sichtbarkeit, tägliche Fein- und Sichtreinigung erforderlich.', photodocBefore: ['https://example.local/before/empfang-1.jpg'], photodocAfter: ['https://example.local/after/empfang-1.jpg'], whiteGloveTest: true, dustMeasurement: 15, createdAt: '2026-03-26T08:00:00', updatedAt: '2026-04-09T13:25:00' }, { id: 'zone_a_eg_sanitaer', code: 'A-EG-010', building: 'Haus A', buildingPart: 'Sanitärkern', floor: 'EG', room: 'WC Damen/Herren', title: 'Sanitärkern EG', usageType: 'sanitaer', phase: 'fein', status: 'qs', acceptanceStatus: 'offen', providerId: 'prov_glanzwerk', length: 5.1, width: 4.2, height: 3.0, manualWindowArea: 3.2, manualFacadeArea: 0, cleanedArea: 72.6, surfaces: ['boden_fliesen', 'wand_fliesen', 'decke_standard', 'fenster'], dirtGrade: 'stark', specialRequirements: [], accessRequirements: ['schluessel'], dueDate: '2026-04-14', notes: 'Zementschleier an Wandfliesen und Armaturen nacharbeiten.', photodocBefore: ['https://example.local/before/wc-1.jpg'], photodocAfter: [], whiteGloveTest: false, dustMeasurement: 26, createdAt: '2026-03-28T09:20:00', updatedAt: '2026-04-12T16:40:00' }, { id: 'zone_a_1og_kueche', code: 'A-1-021', building: 'Haus A', buildingPart: 'Mietbereich Nord', floor: '1. OG', room: 'Teeküche', title: 'Teeküche 1. OG', usageType: 'kueche', phase: 'fein', status: 'nacharbeit', acceptanceStatus: 'nacharbeit', providerId: 'prov_cleanbuild', length: 4.8, width: 3.3, height: 2.9, manualWindowArea: 2.8, manualFacadeArea: 0, cleanedArea: 45.2, surfaces: ['boden_fliesen', 'wand_fliesen', 'decke_standard', 'fenster'], dirtGrade: 'stark', specialRequirements: ['kueche', 'lebensmittel'], accessRequirements: ['schluessel'], dueDate: '2026-04-13', notes: 'Fettrückstände im Sockelbereich und an Schrankfronten.', photodocBefore: ['https://example.local/before/kueche-1.jpg'], photodocAfter: ['https://example.local/after/kueche-1.jpg'], whiteGloveTest: false, dustMeasurement: 34, createdAt: '2026-03-30T08:15:00', updatedAt: '2026-04-12T18:10:00' }, { id: 'zone_a_2og_labor', code: 'A-2-034', building: 'Haus A', buildingPart: 'Labortrakt', floor: '2. OG', room: 'Labor 2.3', title: 'Labor 2.3', usageType: 'labor', phase: 'sicht', status: 'bereit', acceptanceStatus: 'offen', providerId: 'prov_sonder', length: 9.0, width: 5.5, height: 3.1, manualWindowArea: 8.0, manualFacadeArea: 0, cleanedArea: 60.5, surfaces: ['boden_estrich', 'wand_standard', 'decke_standard', 'fenster'], dirtGrade: 'mittel', specialRequirements: ['labor'], accessRequirements: ['schluessel', 'sicherheitsfreigabe'], dueDate: '2026-04-15', notes: 'Freigabe erst nach Installation letzter Labormöbel.', photodocBefore: [], photodocAfter: [], whiteGloveTest: false, dustMeasurement: '', createdAt: '2026-03-31T10:10:00', updatedAt: '2026-04-10T09:10:00' }, { id: 'zone_b_treppe', code: 'B-EG-001', building: 'Haus B', buildingPart: 'Treppenhaus', floor: 'EG-3. OG', room: 'Treppenhaus Süd', title: 'Treppenhaus Süd', usageType: 'treppenhaus', phase: 'zwischen', status: 'in_arbeit', acceptanceStatus: 'offen', providerId: 'prov_cleanbuild', length: 5.0, width: 5.0, height: 12.0, manualWindowArea: 18.0, manualFacadeArea: 0, cleanedArea: 115.0, surfaces: ['boden_fliesen', 'wand_standard', 'fenster'], dirtGrade: 'mittel', specialRequirements: [], accessRequirements: ['abschrankung'], dueDate: '2026-04-16', notes: 'Baubegleitend noch laufender Personenverkehr.', photodocBefore: [], photodocAfter: [], whiteGloveTest: false, dustMeasurement: '', createdAt: '2026-03-27T07:55:00', updatedAt: '2026-04-12T11:30:00' }, { id: 'zone_b_ug_tg', code: 'B-UG-001', building: 'Haus B', buildingPart: 'Untergeschoss', floor: 'UG', room: 'Tiefgarage', title: 'Tiefgarage Ost', usageType: 'tiefgarage', phase: 'sonder', status: 'bereit', acceptanceStatus: 'offen', providerId: 'prov_sonder', length: 42.0, width: 18.0, height: 3.2, manualWindowArea: 0, manualFacadeArea: 0, cleanedArea: 0, surfaces: ['boden_estrich', 'wand_standard'], dirtGrade: 'extrem', specialRequirements: [], accessRequirements: ['abschrankung', 'nachtarbeit'], dueDate: '2026-04-18', notes: 'Ölspuren, Bohrstaub und Reifenabrieb.', photodocBefore: [], photodocAfter: [], whiteGloveTest: false, dustMeasurement: '', createdAt: '2026-04-01T09:45:00', updatedAt: '2026-04-11T10:05:00' }, { id: 'zone_c_1og_op', code: 'C-1-007', building: 'Haus C', buildingPart: 'Klinikbereich', floor: '1. OG', room: 'OP 1', title: 'OP-Saal 1', usageType: 'op', phase: 'fein', status: 'gereinigt', acceptanceStatus: 'bestanden', providerId: 'prov_sonder', length: 7.2, width: 6.8, height: 3.0, manualWindowArea: 5.5, manualFacadeArea: 0, cleanedArea: 139.62, surfaces: ['boden_parkett', 'wand_standard', 'decke_standard', 'fenster'], dirtGrade: 'mittel', specialRequirements: ['op_bereich'], accessRequirements: ['schluessel', 'sicherheitsfreigabe'], dueDate: '2026-04-09', notes: 'Dokumentation White-Glove-Test vorhanden.', photodocBefore: ['https://example.local/before/op1.jpg'], photodocAfter: ['https://example.local/after/op1.jpg'], whiteGloveTest: true, dustMeasurement: 9, createdAt: '2026-03-25T08:45:00', updatedAt: '2026-04-09T14:10:00' }, { id: 'zone_c_1og_reinraum', code: 'C-1-008', building: 'Haus C', buildingPart: 'Klinikbereich', floor: '1. OG', room: 'Reinraum', title: 'Reinraum Schleuse', usageType: 'reinraum', phase: 'sicht', status: 'reklamation', acceptanceStatus: 'gesperrt', providerId: 'prov_sonder', length: 5.4, width: 3.8, height: 2.9, manualWindowArea: 1.2, manualFacadeArea: 0, cleanedArea: 71.9, surfaces: ['boden_parkett', 'wand_standard', 'decke_standard', 'fenster'], dirtGrade: 'leicht', specialRequirements: ['reinraum', 'esd'], accessRequirements: ['schluessel', 'sicherheitsfreigabe'], dueDate: '2026-04-13', notes: 'Feinstaubmessung über Grenzwert; Bereich gesperrt.', photodocBefore: ['https://example.local/before/reinraum.jpg'], photodocAfter: ['https://example.local/after/reinraum.jpg'], whiteGloveTest: false, dustMeasurement: 31, createdAt: '2026-03-29T12:05:00', updatedAt: '2026-04-12T17:22:00' }, { id: 'zone_d_fassade_nord', code: 'D-FAS-001', building: 'Haus D', buildingPart: 'Fassade Nord', floor: 'Außen', room: 'Fassadenachse Nord', title: 'Fassade Nord', usageType: 'fassade', phase: 'sonder', status: 'bereit', acceptanceStatus: 'offen', providerId: 'prov_sonder', length: 0, width: 0, height: 0, manualWindowArea: 65.0, manualFacadeArea: 420.0, cleanedArea: 0, surfaces: ['fenster', 'fassade'], dirtGrade: 'stark', specialRequirements: [], accessRequirements: ['geruest', 'hebebuehne'], dueDate: '2026-04-20', notes: 'Witterungsabhängig, nur trockenes Zeitfenster.', photodocBefore: [], photodocAfter: [], whiteGloveTest: false, dustMeasurement: '', createdAt: '2026-04-02T08:30:00', updatedAt: '2026-04-11T15:15:00' }, { id: 'zone_e_aussen', code: 'E-OUT-001', building: 'Außenanlage', buildingPart: 'Zuwegung West', floor: 'Außen', room: 'Zuwegung', title: 'Außenanlagen West', usageType: 'aussen', phase: 'sonder', status: 'in_arbeit', acceptanceStatus: 'offen', providerId: 'prov_cleanbuild', length: 30.0, width: 4.0, height: 0, manualWindowArea: 0, manualFacadeArea: 0, cleanedArea: 68.0, surfaces: ['boden_estrich'], dirtGrade: 'mittel', specialRequirements: [], accessRequirements: [], dueDate: '2026-04-17', notes: 'Abkehrung und Entfernung Restschotter.', photodocBefore: [], photodocAfter: [], whiteGloveTest: false, dustMeasurement: '', createdAt: '2026-04-03T06:40:00', updatedAt: '2026-04-12T07:55:00' } ], serviceCatalog: [ { id: 'srv_grob_boden_estrich_leicht', title: 'Grobreinigung Estrich leicht', phase: 'grob', surfaceType: 'boden_estrich', dirtGrade: 'leicht', description: 'Bauschutt aufnehmen, Grobstaub entfernen, einmal nachsaugen.', minutesPerSqm: 2.0, costPerSqm: 1.3, specialCleaning: false, active: true }, { id: 'srv_grob_boden_estrich_stark', title: 'Grobreinigung Estrich stark', phase: 'grob', surfaceType: 'boden_estrich', dirtGrade: 'stark', description: 'Bauschutt, Mörtelreste, Staub und Klebereste entfernen.', minutesPerSqm: 4.8, costPerSqm: 2.3, specialCleaning: false, active: true }, { id: 'srv_zw_boden_fliesen_mittel', title: 'Zwischenreinigung Fliesen mittel', phase: 'zwischen', surfaceType: 'boden_fliesen', dirtGrade: 'mittel', description: 'Staub, leichten Zementschleier und Laufspuren entfernen.', minutesPerSqm: 1.9, costPerSqm: 1.8, specialCleaning: false, active: true }, { id: 'srv_zw_wand_standard_mittel', title: 'Zwischenreinigung Wand standard', phase: 'zwischen', surfaceType: 'wand_standard', dirtGrade: 'mittel', description: 'Bauschmutz trocken aufnehmen und Oberflächen staubarm halten.', minutesPerSqm: 1.6, costPerSqm: 1.2, specialCleaning: false, active: true }, { id: 'srv_fein_boden_fliesen_mittel', title: 'Feinreinigung Boden Fliesen', phase: 'fein', surfaceType: 'boden_fliesen', dirtGrade: 'mittel', description: 'Nassreinigung, rückstandsfreie Trocknung, Sockelreinigung.', minutesPerSqm: 2.6, costPerSqm: 3.4, specialCleaning: false, active: true }, { id: 'srv_fein_boden_fliesen_stark', title: 'Feinreinigung Boden Fliesen stark', phase: 'fein', surfaceType: 'boden_fliesen', dirtGrade: 'stark', description: 'Zementschleier entfernen, Politurkontrolle, Ecken nacharbeiten.', minutesPerSqm: 3.8, costPerSqm: 4.4, specialCleaning: false, active: true }, { id: 'srv_fein_wand_fliesen_stark', title: 'Feinreinigung Wand Fliesen stark', phase: 'fein', surfaceType: 'wand_fliesen', dirtGrade: 'stark', description: 'Armaturen, Fliesenspiegel, Fugen und Silikonfugen reinigen.', minutesPerSqm: 4.2, costPerSqm: 4.9, specialCleaning: false, active: true }, { id: 'srv_fein_fenster_mittel', title: 'Feinreinigung Fenster', phase: 'fein', surfaceType: 'fenster', dirtGrade: 'mittel', description: 'Glas innen/außen, Rahmen, Falz, Etikettenreste entfernen.', minutesPerSqm: 5.0, costPerSqm: 6.2, specialCleaning: false, active: true }, { id: 'srv_sicht_boden_fliesen_leicht', title: 'Sichtreinigung Boden Fliesen', phase: 'sicht', surfaceType: 'boden_fliesen', dirtGrade: 'leicht', description: 'Laufspuren beseitigen, punktuelle Nachreinigung.', minutesPerSqm: 0.9, costPerSqm: 1.2, specialCleaning: false, active: true }, { id: 'srv_sicht_boden_parkett_leicht', title: 'Sichtreinigung Parkett', phase: 'sicht', surfaceType: 'boden_parkett', dirtGrade: 'leicht', description: 'Nebelfeucht reinigen, streifenfrei abziehen.', minutesPerSqm: 1.1, costPerSqm: 1.8, specialCleaning: false, active: true }, { id: 'srv_sicht_wand_standard_leicht', title: 'Sichtreinigung Wand standard', phase: 'sicht', surfaceType: 'wand_standard', dirtGrade: 'leicht', description: 'Finger- und Wischspuren entfernen.', minutesPerSqm: 0.7, costPerSqm: 1.0, specialCleaning: false, active: true }, { id: 'srv_sicht_fenster_leicht', title: 'Sichtreinigung Fenster leicht', phase: 'sicht', surfaceType: 'fenster', dirtGrade: 'leicht', description: 'Streifenfreie Sichtreinigung vor Übergabe.', minutesPerSqm: 2.0, costPerSqm: 2.6, specialCleaning: false, active: true }, { id: 'srv_sonder_fassade_stark', title: 'Fassadenreinigung stark', phase: 'sonder', surfaceType: 'fassade', dirtGrade: 'stark', description: 'Hochdruck- und Chemieeinsatz gemäß Fassadenhersteller.', minutesPerSqm: 4.9, costPerSqm: 8.5, specialCleaning: true, active: true }, { id: 'srv_sonder_boden_estrich_extrem', title: 'Tiefgarage Spezialreinigung', phase: 'sonder', surfaceType: 'boden_estrich', dirtGrade: 'extrem', description: 'Maschinenreinigung, Ölbindemittel, Heißwasserverfahren.', minutesPerSqm: 6.4, costPerSqm: 7.8, specialCleaning: true, active: true }, { id: 'srv_sonder_wand_standard_extrem', title: 'Tiefgarage Wand Spezialreinigung', phase: 'sonder', surfaceType: 'wand_standard', dirtGrade: 'extrem', description: 'Schmierfilm und Bauablagerungen entfernen.', minutesPerSqm: 5.1, costPerSqm: 5.9, specialCleaning: true, active: true }, { id: 'srv_fein_boden_parkett_mittel', title: 'Feinreinigung Parkett medizinisch', phase: 'fein', surfaceType: 'boden_parkett', dirtGrade: 'mittel', description: 'Schonende Reinigung sensibler Oberflächen.', minutesPerSqm: 2.9, costPerSqm: 4.0, specialCleaning: false, active: true }, { id: 'srv_fein_wand_standard_mittel', title: 'Feinreinigung Wand standard', phase: 'fein', surfaceType: 'wand_standard', dirtGrade: 'mittel', description: 'Staubfrei, rückstandsfrei, schattenfreie Optik.', minutesPerSqm: 2.0, costPerSqm: 2.5, specialCleaning: false, active: true }, { id: 'srv_fein_decke_standard_mittel', title: 'Feinreinigung Decke', phase: 'fein', surfaceType: 'decke_standard', dirtGrade: 'mittel', description: 'Staub- und fleckenfreie Deckenflächen.', minutesPerSqm: 1.7, costPerSqm: 2.1, specialCleaning: false, active: true }, { id: 'srv_sicht_decke_standard_leicht', title: 'Sichtreinigung Decke', phase: 'sicht', surfaceType: 'decke_standard', dirtGrade: 'leicht', description: 'Feinstaub-Check und punktuelle Nacharbeit.', minutesPerSqm: 0.6, costPerSqm: 0.9, specialCleaning: false, active: true }, { id: 'srv_sonder_fenster_stark', title: 'Glas/Fassaden-Sonderreinigung', phase: 'sonder', surfaceType: 'fenster', dirtGrade: 'stark', description: 'Bauetiketten, Silikonreste und Fassadenfilm.', minutesPerSqm: 5.8, costPerSqm: 7.2, specialCleaning: true, active: true } ], checklistTemplates: [ { id: 'chk_buero_standard', title: 'Büro Standard-Abnahme', usageType: 'buero', criteria: ['staubfreiheit', 'streifenfreiheit', 'rueckstandsfreiheit', 'arbeitsmittel'], notes: 'Besonderes Augenmerk auf Fensterbänke, Türen und Schalterprogramme.', active: true }, { id: 'chk_sanitaer', title: 'Sanitär-Abnahme', usageType: 'sanitaer', criteria: ['staubfreiheit', 'streifenfreiheit', 'rueckstandsfreiheit', 'sanitaerhygiene', 'fugen'], notes: 'Armaturen, Spiegel, Silikonfugen und Bodenabläufe prüfen.', active: true }, { id: 'chk_labor_op', title: 'Labor / OP-Abnahme', usageType: 'op', criteria: ['staubfreiheit', 'rueckstandsfreiheit', 'glanz', 'arbeitsmittel'], notes: 'White-Glove-Test dokumentieren, Feinstaubwert erfassen.', active: true }, { id: 'chk_reinraum', title: 'Reinraum-Abnahme', usageType: 'reinraum', criteria: ['staubfreiheit', 'rueckstandsfreiheit', 'glanz'], notes: 'Partikel-/Feinstaubwerte und Sperrstatus dokumentieren.', active: true }, { id: 'chk_kueche', title: 'Küchen-Abnahme', usageType: 'kueche', criteria: ['staubfreiheit', 'streifenfreiheit', 'rueckstandsfreiheit', 'geruch'], notes: 'Schrankfronten, Sockel, Fliesenspiegel und Geräteanschlüsse prüfen.', active: true } ], inspections: [ { id: 'insp_empfang_1', zoneId: 'zone_a_eg_empfang', providerId: 'prov_glanzwerk', checklistTemplateId: 'chk_buero_standard', result: 'bestanden', workflowStage: 'freigabe', whiteGlovePassed: true, dustMeasurement: 15, score: 96, beforePhotos: ['https://example.local/before/empfang-1.jpg'], afterPhotos: ['https://example.local/after/empfang-1.jpg'], findings: 'Übergabefähig. Kleine Korrektur an Eingangsmatte bereits erledigt.', inspector: 'QS Meyer', inspectedAt: '2026-04-09T13:15:00', firstPass: true, stars: 5, nextDueDate: '' }, { id: 'insp_wc_1', zoneId: 'zone_a_eg_sanitaer', providerId: 'prov_glanzwerk', checklistTemplateId: 'chk_sanitaer', result: 'offen', workflowStage: 'qs', whiteGlovePassed: false, dustMeasurement: 26, score: 78, beforePhotos: ['https://example.local/before/wc-1.jpg'], afterPhotos: [], findings: 'Armaturen mit Kalkschatten, Fugen partiell verschmutzt.', inspector: 'QS Meyer', inspectedAt: '2026-04-12T15:30:00', firstPass: false, stars: 3, nextDueDate: '2026-04-14' }, { id: 'insp_kueche_1', zoneId: 'zone_a_1og_kueche', providerId: 'prov_cleanbuild', checklistTemplateId: 'chk_kueche', result: 'nacharbeit', workflowStage: 'nacharbeit', whiteGlovePassed: false, dustMeasurement: 34, score: 68, beforePhotos: ['https://example.local/before/kueche-1.jpg'], afterPhotos: ['https://example.local/after/kueche-1.jpg'], findings: 'Fettrückstände und Sockelbereiche unvollständig gereinigt.', inspector: 'Bauleiter Schmitt', inspectedAt: '2026-04-12T18:00:00', firstPass: false, stars: 2, nextDueDate: '2026-04-13' }, { id: 'insp_op_1', zoneId: 'zone_c_1og_op', providerId: 'prov_sonder', checklistTemplateId: 'chk_labor_op', result: 'bestanden', workflowStage: 'freigabe', whiteGlovePassed: true, dustMeasurement: 9, score: 98, beforePhotos: ['https://example.local/before/op1.jpg'], afterPhotos: ['https://example.local/after/op1.jpg'], findings: 'Sehr gute Leistung, vollständige Dokumentation.', inspector: 'Hygiene Kühn', inspectedAt: '2026-04-09T14:00:00', firstPass: true, stars: 5, nextDueDate: '' }, { id: 'insp_reinraum_1', zoneId: 'zone_c_1og_reinraum', providerId: 'prov_sonder', checklistTemplateId: 'chk_reinraum', result: 'nacharbeit', workflowStage: 'bauleiter', whiteGlovePassed: false, dustMeasurement: 31, score: 61, beforePhotos: ['https://example.local/before/reinraum.jpg'], afterPhotos: ['https://example.local/after/reinraum.jpg'], findings: 'Feinstaubmessung überschritten. Sperrung bleibt aktiv.', inspector: 'Hygiene Kühn', inspectedAt: '2026-04-12T17:10:00', firstPass: false, stars: 2, nextDueDate: '2026-04-13' }, { id: 'insp_treppe_1', zoneId: 'zone_b_treppe', providerId: 'prov_cleanbuild', checklistTemplateId: 'chk_buero_standard', result: 'offen', workflowStage: 'reinigung', whiteGlovePassed: false, dustMeasurement: '', score: 0, beforePhotos: [], afterPhotos: [], findings: 'Noch in Bearbeitung.', inspector: '', inspectedAt: '', firstPass: true, stars: 0, nextDueDate: '2026-04-16' } ], defects: [ { id: 'def_wc_armaturen', zoneId: 'zone_a_eg_sanitaer', inspectionId: 'insp_wc_1', providerId: 'prov_glanzwerk', title: 'Armaturen mit Kalkschatten', severity: 'mittel', description: 'Mischbatterien nicht streifenfrei, Rückstände im Ablaufbereich.', dueDate: '2026-04-14', status: 'offen', createdAt: '2026-04-12T15:45:00', resolvedAt: '' }, { id: 'def_wc_fugen', zoneId: 'zone_a_eg_sanitaer', inspectionId: 'insp_wc_1', providerId: 'prov_glanzwerk', title: 'Fugen partiell verschmutzt', severity: 'hoch', description: 'Wand-Boden-Anschluss mit Zementschleier.', dueDate: '2026-04-14', status: 'offen', createdAt: '2026-04-12T15:47:00', resolvedAt: '' }, { id: 'def_kueche_sockel', zoneId: 'zone_a_1og_kueche', inspectionId: 'insp_kueche_1', providerId: 'prov_cleanbuild', title: 'Sockelbereich unvollständig gereinigt', severity: 'mittel', description: 'Fettfilm und Staub im verdeckten Bereich.', dueDate: '2026-04-13', status: 'offen', createdAt: '2026-04-12T18:05:00', resolvedAt: '' }, { id: 'def_reinraum_feinstaub', zoneId: 'zone_c_1og_reinraum', inspectionId: 'insp_reinraum_1', providerId: 'prov_sonder', title: 'Feinstaubmessung über Grenzwert', severity: 'kritisch', description: 'Messwert 31 µg/m³, Grenzwert intern 20 µg/m³.', dueDate: '2026-04-13', status: 'offen', createdAt: '2026-04-12T17:20:00', resolvedAt: '' } ], providers: [ { id: 'prov_glanzwerk', name: 'GlanzWerk GmbH', contactPerson: 'Sabine Korte', email: 'einsatz@glanzwerk.example', phone: '+49 201 555010', frameworkContract: 'RV-2026-01', rateGrob: 1.8, rateZwischen: 2.4, rateFein: 3.8, rateSicht: 1.4, responseHours: 8, notes: 'Stark bei Standardflächen und Sanitärbereichen.', active: true }, { id: 'prov_cleanbuild', name: 'CleanBuild Services', contactPerson: 'Marc Held', email: 'projekt@cleanbuild.example', phone: '+49 203 555220', frameworkContract: 'RV-2026-03', rateGrob: 1.6, rateZwischen: 2.1, rateFein: 3.3, rateSicht: 1.3, responseHours: 12, notes: 'Preislich stark, Qualität schwankt bei sensiblen Bereichen.', active: true }, { id: 'prov_sonder', name: 'NordWest Sonderreinigung', contactPerson: 'Leonie Maas', email: 'service@nwsr.example', phone: '+49 211 555330', frameworkContract: 'RV-2026-07', rateGrob: 2.5, rateZwischen: 3.0, rateFein: 4.6, rateSicht: 2.0, responseHours: 6, notes: 'Spezialist für Reinraum, OP und Fassaden.', active: true } ], contracts: [ { id: 'contract_1', providerId: 'prov_glanzwerk', contractNo: 'RV-2026-01', validFrom: '2026-01-01', validTo: '2026-12-31', clauses: 'Sanitär- und Standardflächen, Nacharbeit binnen 24h.' }, { id: 'contract_2', providerId: 'prov_cleanbuild', contractNo: 'RV-2026-03', validFrom: '2026-02-01', validTo: '2026-12-31', clauses: 'Allgemeine Bauendreinigung, Außenanlagen, Staffelkonditionen ab 1.500 m².' }, { id: 'contract_3', providerId: 'prov_sonder', contractNo: 'RV-2026-07', validFrom: '2026-03-01', validTo: '2027-02-28', clauses: 'Reinraum-/OP-Reinigung, Fassaden, Tiefgarage, Sonderchemie nach Freigabe.' } ], assignments: [ { id: 'assign_empfang', providerId: 'prov_glanzwerk', zoneId: 'zone_a_eg_empfang', phase: 'sicht', orderNo: 'BGR-1001', startDate: '2026-04-08', dueDate: '2026-04-09', commissionedArea: 137.54, measuredArea: 137.54, ratePerSqm: 1.4, crewSize: 2, commissionedMinutes: 150, actualMinutes: 142, status: 'abgeschlossen', notes: 'Sichtreinigung vor Musterbegehung.', qualityRating: 5 }, { id: 'assign_wc', providerId: 'prov_glanzwerk', zoneId: 'zone_a_eg_sanitaer', phase: 'fein', orderNo: 'BGR-1002', startDate: '2026-04-11', dueDate: '2026-04-14', commissionedArea: 72.6, measuredArea: 72.6, ratePerSqm: 3.8, crewSize: 2, commissionedMinutes: 246, actualMinutes: 268, status: 'in_pruefung', notes: 'Armaturen nacharbeiten.', qualityRating: 3 }, { id: 'assign_kueche', providerId: 'prov_cleanbuild', zoneId: 'zone_a_1og_kueche', phase: 'fein', orderNo: 'BGR-1003', startDate: '2026-04-11', dueDate: '2026-04-13', commissionedArea: 63.52, measuredArea: 63.52, ratePerSqm: 3.3, crewSize: 2, commissionedMinutes: 258, actualMinutes: 205, status: 'nacharbeit', notes: 'Auf Nacharbeit gesetzt.', qualityRating: 2 }, { id: 'assign_treppe', providerId: 'prov_cleanbuild', zoneId: 'zone_b_treppe', phase: 'zwischen', orderNo: 'BGR-1004', startDate: '2026-04-12', dueDate: '2026-04-16', commissionedArea: 258, measuredArea: 258, ratePerSqm: 2.1, crewSize: 3, commissionedMinutes: 464, actualMinutes: 210, status: 'in_arbeit', notes: 'Noch laufender Baustellenverkehr.', qualityRating: 0 }, { id: 'assign_op', providerId: 'prov_sonder', zoneId: 'zone_c_1og_op', phase: 'fein', orderNo: 'BGR-1005', startDate: '2026-04-08', dueDate: '2026-04-09', commissionedArea: 139.62, measuredArea: 139.62, ratePerSqm: 4.6, crewSize: 3, commissionedMinutes: 405, actualMinutes: 392, status: 'abgeschlossen', notes: 'Medizinischer Bereich.', qualityRating: 5 }, { id: 'assign_reinraum', providerId: 'prov_sonder', zoneId: 'zone_c_1og_reinraum', phase: 'sicht', orderNo: 'BGR-1006', startDate: '2026-04-12', dueDate: '2026-04-13', commissionedArea: 71.9, measuredArea: 71.9, ratePerSqm: 2.0, crewSize: 2, commissionedMinutes: 96, actualMinutes: 112, status: 'reklamation', notes: 'Reklamation wegen Feinstaub.', qualityRating: 2 }, { id: 'assign_tg', providerId: 'prov_sonder', zoneId: 'zone_b_ug_tg', phase: 'sonder', orderNo: 'BGR-1007', startDate: '2026-04-17', dueDate: '2026-04-18', commissionedArea: 1140, measuredArea: 0, ratePerSqm: 7.8, crewSize: 4, commissionedMinutes: 6840, actualMinutes: 0, status: 'beauftragt', notes: 'Nachtarbeit und Absperrung erforderlich.', qualityRating: 0 }, { id: 'assign_fassade', providerId: 'prov_sonder', zoneId: 'zone_d_fassade_nord', phase: 'sonder', orderNo: 'BGR-1008', startDate: '2026-04-19', dueDate: '2026-04-20', commissionedArea: 485, measuredArea: 0, ratePerSqm: 8.5, crewSize: 5, commissionedMinutes: 2377, actualMinutes: 0, status: 'beauftragt', notes: 'Wetterfenster abwarten.', qualityRating: 0 } ], timesheets: [ { id: 'ts_1', providerId: 'prov_glanzwerk', assignmentId: 'assign_empfang', date: '2026-04-08', staffCount: 2, hours: 4, approved: true, notes: 'Abendshift Lobby.' }, { id: 'ts_2', providerId: 'prov_glanzwerk', assignmentId: 'assign_wc', date: '2026-04-11', staffCount: 2, hours: 5, approved: true, notes: 'Feinreinigung Sanitär.' }, { id: 'ts_3', providerId: 'prov_glanzwerk', assignmentId: 'assign_wc', date: '2026-04-12', staffCount: 2, hours: 4, approved: false, notes: 'Nacharbeit angekündigt.' }, { id: 'ts_4', providerId: 'prov_cleanbuild', assignmentId: 'assign_kueche', date: '2026-04-12', staffCount: 2, hours: 3.5, approved: false, notes: 'Feinreinigung Küche.' }, { id: 'ts_5', providerId: 'prov_cleanbuild', assignmentId: 'assign_treppe', date: '2026-04-12', staffCount: 3, hours: 4, approved: false, notes: 'Zwischenreinigung Treppenhaus.' }, { id: 'ts_6', providerId: 'prov_sonder', assignmentId: 'assign_op', date: '2026-04-08', staffCount: 3, hours: 5.5, approved: true, notes: 'OP-Bereich.' }, { id: 'ts_7', providerId: 'prov_sonder', assignmentId: 'assign_reinraum', date: '2026-04-12', staffCount: 2, hours: 4, approved: false, notes: 'Sichtreinigung Reinraum.' } ], complaints: [ { id: 'compl_1', providerId: 'prov_cleanbuild', zoneId: 'zone_a_1og_kueche', inspectionId: 'insp_kueche_1', severity: 'hoch', escalationLevel: 2, title: 'Nacharbeit Küche', description: 'Kunde bemängelt Sockel- und Frontbereiche.', status: 'offen', dueDate: '2026-04-13', createdAt: '2026-04-12T18:15:00', resolvedAt: '' }, { id: 'compl_2', providerId: 'prov_sonder', zoneId: 'zone_c_1og_reinraum', inspectionId: 'insp_reinraum_1', severity: 'kritisch', escalationLevel: 3, title: 'Feinstaubgrenzwert überschritten', description: 'Bereich bis Nacharbeit gesperrt.', status: 'offen', dueDate: '2026-04-13', createdAt: '2026-04-12T17:25:00', resolvedAt: '' } ], invoices: [ { id: 'inv_1', providerId: 'prov_glanzwerk', assignmentId: 'assign_empfang', invoiceNo: 'GW-2026-188', date: '2026-04-10', billedArea: 137.54, amountNet: 192.56, amountGross: 229.15, status: 'geprueft', notes: 'Ohne Abweichung.' }, { id: 'inv_2', providerId: 'prov_glanzwerk', assignmentId: 'assign_wc', invoiceNo: 'GW-2026-191', date: '2026-04-13', billedArea: 75.0, amountNet: 285.00, amountGross: 339.15, status: 'offen', notes: 'Fläche größer als Aufmaß, Prüfung offen.' }, { id: 'inv_3', providerId: 'prov_cleanbuild', assignmentId: 'assign_kueche', invoiceNo: 'CB-2026-074', date: '2026-04-13', billedArea: 63.52, amountNet: 209.62, amountGross: 249.45, status: 'gesperrt', notes: 'Nacharbeit offen, Rechnung gesperrt.' }, { id: 'inv_4', providerId: 'prov_sonder', assignmentId: 'assign_op', invoiceNo: 'NSR-2026-033', date: '2026-04-10', billedArea: 139.62, amountNet: 642.25, amountGross: 764.28, status: 'geprueft', notes: 'Freigegeben.' } ], materials: [ { id: 'mat_neutral', name: 'Neutralreiniger pH 7', category: 'Allzweckreiniger', unit: 'l', stock: 84, minStock: 25, hazardClass: 'kein', sdsUrl: 'https://example.local/sds/neutralreiniger.pdf', applicationSurfaceTypes: ['boden_fliesen', 'wand_standard', 'decke_standard'], handlingNotes: 'Für empfindliche Oberflächen geeignet.', ecoCertifications: ['eu_ecolabel'], active: true }, { id: 'mat_zementschleier', name: 'Zementschleierentferner', category: 'Spezialchemie', unit: 'l', stock: 12, minStock: 10, hazardClass: 'aetzend', sdsUrl: 'https://example.local/sds/zementschleier.pdf', applicationSurfaceTypes: ['boden_fliesen', 'wand_fliesen'], handlingNotes: 'Nur mit Schutzhandschuhen und Vorversuch.', ecoCertifications: [], active: true }, { id: 'mat_glas', name: 'Glasreiniger Konzentrat', category: 'Glasreiniger', unit: 'l', stock: 32, minStock: 12, hazardClass: 'reizend', sdsUrl: 'https://example.local/sds/glas.pdf', applicationSurfaceTypes: ['fenster'], handlingNotes: 'Streifenfrei verarbeiten.', ecoCertifications: ['blauer_engel'], active: true }, { id: 'mat_desinfektion', name: 'Flächendesinfektion', category: 'Hygiene', unit: 'l', stock: 28, minStock: 15, hazardClass: 'entzundbar', sdsUrl: 'https://example.local/sds/desinfektion.pdf', applicationSurfaceTypes: ['boden_parkett', 'wand_standard'], handlingNotes: 'OP-/Laborbereich gemäß Hygienefreigabe.', ecoCertifications: [], active: true }, { id: 'mat_mikrofaser', name: 'Mikrofasertuch blau', category: 'Tuch', unit: 'stk', stock: 210, minStock: 80, hazardClass: 'kein', sdsUrl: '', applicationSurfaceTypes: ['wand_standard', 'fenster', 'decke_standard'], handlingNotes: 'Farbkodierung beachten.', ecoCertifications: ['cradle_to_cradle'], active: true }, { id: 'mat_pad_rot', name: 'Pad rot 17 Zoll', category: 'Pad', unit: 'stk', stock: 14, minStock: 8, hazardClass: 'kein', sdsUrl: '', applicationSurfaceTypes: ['boden_estrich', 'boden_fliesen'], handlingNotes: 'Für Grund- und Intensivreinigung.', ecoCertifications: [], active: true }, { id: 'mat_oelbinder', name: 'Ölbindemittel', category: 'Spezialmaterial', unit: 'kg', stock: 90, minStock: 50, hazardClass: 'kein', sdsUrl: '', applicationSurfaceTypes: ['boden_estrich'], handlingNotes: 'Tiefgarage und Außenflächen.', ecoCertifications: [], active: true }, { id: 'mat_reinraum', name: 'Reinraum-Wipes steril', category: 'Spezialmaterial', unit: 'pkt', stock: 26, minStock: 20, hazardClass: 'kein', sdsUrl: '', applicationSurfaceTypes: ['boden_parkett', 'wand_standard'], handlingNotes: 'Nur in Reinraum-/OP-Bereichen.', ecoCertifications: [], active: true }, { id: 'mat_politur', name: 'Edelstahlpflege', category: 'Pflege', unit: 'l', stock: 6, minStock: 8, hazardClass: 'reizend', sdsUrl: 'https://example.local/sds/edelstahl.pdf', applicationSurfaceTypes: ['wand_standard'], handlingNotes: 'Niedriger Bestand.', ecoCertifications: ['eu_ecolabel'], active: true } ], stockMovements: [ { id: 'mv_1', materialId: 'mat_neutral', type: 'zugang', quantity: 50, date: '2026-04-01', notes: 'Monatslieferung' }, { id: 'mv_2', materialId: 'mat_zementschleier', type: 'verbrauch', quantity: 5, date: '2026-04-12', notes: 'Sanitärkern EG' }, { id: 'mv_3', materialId: 'mat_glas', type: 'verbrauch', quantity: 8, date: '2026-04-09', notes: 'Empfang / Fenster' }, { id: 'mv_4', materialId: 'mat_desinfektion', type: 'verbrauch', quantity: 6, date: '2026-04-09', notes: 'OP-Bereich' }, { id: 'mv_5', materialId: 'mat_reinraum', type: 'verbrauch', quantity: 4, date: '2026-04-12', notes: 'Reinraum Schleuse' }, { id: 'mv_6', materialId: 'mat_politur', type: 'verbrauch', quantity: 3, date: '2026-04-11', notes: 'Armaturen und Edelstahl' } ], machines: [ { id: 'mach_1', name: 'Nilfisk SC550', type: 'scheuersaugmaschine', serialNo: 'NSC550-11', status: 'verfuegbar', lastServiceDate: '2026-02-14', nextServiceDate: '2026-05-14', providerId: '', notes: 'Standard für Fliesenflächen.' }, { id: 'mach_2', name: 'Kärcher SGV 8/5', type: 'dampfreiniger', serialNo: 'KSGV-08', status: 'im_einsatz', lastServiceDate: '2026-03-10', nextServiceDate: '2026-06-10', providerId: 'prov_sonder', notes: 'Labor / OP / Küche.' }, { id: 'mach_3', name: 'Kärcher HD 6/15', type: 'hochdruckreiniger', serialNo: 'KHD-615', status: 'verfuegbar', lastServiceDate: '2026-01-22', nextServiceDate: '2026-04-22', providerId: 'prov_sonder', notes: 'Fassade / Tiefgarage.' }, { id: 'mach_4', name: 'Festool CTM 48', type: 'industriesauger', serialNo: 'FCTM-48', status: 'wartung', lastServiceDate: '2026-04-05', nextServiceDate: '2026-04-19', providerId: '', notes: 'Filterwechsel offen.' } ], reports: [ { id: 'report_1', type: 'fortschritt', title: 'Fortschrittsbericht KW 15', generatedAt: '2026-04-12T19:00:00', summary: 'Stand 12.04.2026, 54,8 % gereinigt.', rows: [] } ] }; } function apiCall(endpoint, method, data) { var cleanedEndpoint = String(endpoint || '').replace(/^\/+/, ''); var httpMethod = String(method || 'GET').toUpperCase(); var url = API_BASE + '/' + cleanedEndpoint; var headers = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }; var options = { method: httpMethod, headers: headers, credentials: 'same-origin' }; var controller = new AbortController(); options.signal = controller.signal; state.runtime.abortControllers.push(controller); if (httpMethod === 'GET' && data && Object.keys(data).length) { var query = buildQueryString(data); if (query) { url += (url.indexOf('?') === -1 ? '?' : '&') + query; } } else if (data !== undefined && data !== null) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(data); } debugLog('API', httpMethod, url, data || null); return fetch(url, options) .then(function(response) { var contentType = response.headers.get('content-type') || ''; if (!response.ok) { return response.text().then(function(text) { var error = new Error('API-Fehler ' + response.status + ' bei ' + cleanedEndpoint + (text ? ': ' + text : '')); error.status = response.status; throw error; }); } if (contentType.indexOf('application/json') !== -1) { return response.json(); } if (contentType.indexOf('text/') !== -1) { return response.text(); } return response.blob(); }) .finally(function() { state.runtime.abortControllers = state.runtime.abortControllers.filter(function(item) { return item !== controller; }); }); } function loadBootstrapData() { setLoading(true); clearError(); return apiCall('bootstrap', 'GET') .then(function(payload) { normalizeBootstrapPayload(payload && payload.data ? payload.data : payload); state.lastSync = nowIso(); return payload; }) .catch(function(error) { setLastError(error); if (state.options.useSeedOnError) { normalizeBootstrapPayload(createSeedData()); showToast('API nicht erreichbar. Modul läuft mit Seed-Daten.', 'warning', 5000); return null; } throw error; }) .finally(function() { setLoading(false); }); } function refreshDashboardData() { return apiCall('dashboard', 'GET') .then(function(payload) { state.data.dashboard = payload && payload.data ? payload.data : payload; }) .catch(function() { state.data.dashboard = null; }); } function persistRecord(collectionName, endpoint, method, record) { var resolvedMethod = method || (recordExists(collectionName, record.id) ? 'PUT' : 'POST'); var path = endpoint; if (resolvedMethod === 'PUT' || resolvedMethod === 'PATCH') { path += '/' + encodeURIComponent(record.id); } return apiCall(path, resolvedMethod, record) .then(function(payload) { var result = payload && payload.data ? payload.data : payload; if (result && typeof result === 'object') { return result; } return record; }) .catch(function(error) { setLastError(error); showToast('API-Speicherung fehlgeschlagen. Datensatz lokal fortgeführt.', 'warning'); return record; }); } function deletePersistedRecord(endpoint, id) { return apiCall(endpoint + '/' + encodeURIComponent(id), 'DELETE') .catch(function(error) { setLastError(error); showToast('API-Löschung fehlgeschlagen. Datensatz nur lokal entfernt.', 'warning'); }); } function recalcDashboardSnapshot() { state.data.dashboard = computeDashboardMetrics(); } function selectBuildingParts() { return unique(state.data.zones.map(function(zone) { return zone.buildingPart; }).filter(Boolean)).sort(function(a, b) { return a.localeCompare(b, 'de'); }); } function selectFloors() { return unique(state.data.zones.map(function(zone) { return zone.floor; }).filter(Boolean)).sort(function(a, b) { return a.localeCompare(b, 'de'); }); } function matchesQuery(entity, extraText) { var query = String(state.filters.query || '').trim().toLowerCase(); if (!query) { return true; } var text = getCommonText(entity) + ' ' + (extraText || ''); return text.indexOf(query) !== -1; } function matchesZoneFilters(zone) { if (!zone) { return false; } if (state.filters.phase && zone.phase !== state.filters.phase) { return false; } if (state.filters.status && zone.status !== state.filters.status) { return false; } if (state.filters.buildingPart && zone.buildingPart !== state.filters.buildingPart) { return false; } if (state.filters.floor && zone.floor !== state.filters.floor) { return false; } if (state.filters.acceptanceStatus && zone.acceptanceStatus !== state.filters.acceptanceStatus) { return false; } if (state.filters.providerId && zone.providerId !== state.filters.providerId) { return false; } if (!matchesDateRange(zone.dueDate, state.filters.periodFrom, state.filters.periodTo)) { return false; } var provider = getProviderById(zone.providerId); return matchesQuery(zone, provider ? provider.name : ''); } function matchesServiceFilters(service) { if (!service) { return false; } if (state.filters.phase && service.phase !== state.filters.phase) { return false; } return matchesQuery(service, getLookupLabel(state.lookups.surfaceTypes, service.surfaceType, '')); } function matchesInspectionFilters(inspection) { if (!inspection) { return false; } var zone = getZoneById(inspection.zoneId); if (state.filters.phase && zone && zone.phase !== state.filters.phase) { return false; } if (state.filters.buildingPart && zone && zone.buildingPart !== state.filters.buildingPart) { return false; } if (state.filters.floor && zone && zone.floor !== state.filters.floor) { return false; } if (state.filters.acceptanceStatus && inspection.result !== state.filters.acceptanceStatus && zone && zone.acceptanceStatus !== state.filters.acceptanceStatus) { return false; } if (state.filters.providerId && inspection.providerId !== state.filters.providerId) { return false; } if (!matchesDateRange(inspection.inspectedAt || inspection.nextDueDate, state.filters.periodFrom, state.filters.periodTo)) { return false; } var provider = getProviderById(inspection.providerId); return matchesQuery(inspection, [zone ? zone.title : '', provider ? provider.name : ''].join(' ')); } function matchesDefectFilters(defect) { if (!defect) { return false; } var zone = getZoneById(defect.zoneId); if (state.filters.phase && zone && zone.phase !== state.filters.phase) { return false; } if (state.filters.buildingPart && zone && zone.buildingPart !== state.filters.buildingPart) { return false; } if (state.filters.floor && zone && zone.floor !== state.filters.floor) { return false; } if (state.filters.providerId && defect.providerId !== state.filters.providerId) { return false; } if (state.filters.status && defect.status !== state.filters.status) { return false; } if (!matchesDateRange(defect.dueDate || defect.createdAt, state.filters.periodFrom, state.filters.periodTo)) { return false; } var provider = getProviderById(defect.providerId); return matchesQuery(defect, [zone ? zone.title : '', provider ? provider.name : ''].join(' ')); } function matchesProviderFilters(provider) { if (!provider) { return false; } if (state.filters.providerId && provider.id !== state.filters.providerId) { return false; } return matchesQuery(provider, ''); } function matchesMaterialFilters(material) { if (!material) { return false; } if (state.filters.status && state.filters.status === 'unterbestand') { if (toNumber(material.stock, 0) >= toNumber(material.minStock, 0)) { return false; } } return matchesQuery(material, getMultipleLookupLabels(state.lookups.ecoCertifications, material.ecoCertifications).join(' ')); } function selectFilteredZones() { return state.data.zones .filter(matchesZoneFilters) .sort(compareBy('dueDate', 'asc')); } function selectFilteredServiceCatalog() { return state.data.serviceCatalog .filter(matchesServiceFilters) .sort(compareBy('phase', 'asc')); } function selectFilteredInspections() { return state.data.inspections .filter(matchesInspectionFilters) .sort(compareBy('inspectedAt', 'desc')); } function selectFilteredDefects() { return state.data.defects .filter(matchesDefectFilters) .sort(compareBy('dueDate', 'asc')); } function selectFilteredProviders() { return state.data.providers .filter(matchesProviderFilters) .sort(compareBy('name', 'asc')); } function selectFilteredAssignments() { return state.data.assignments.filter(function(assignment) { var zone = getZoneById(assignment.zoneId); var provider = getProviderById(assignment.providerId); if (state.filters.phase && assignment.phase !== state.filters.phase) { return false; } if (state.filters.providerId && assignment.providerId !== state.filters.providerId) { return false; } if (state.filters.buildingPart && zone && zone.buildingPart !== state.filters.buildingPart) { return false; } if (state.filters.floor && zone && zone.floor !== state.filters.floor) { return false; } if (state.filters.status && assignment.status !== state.filters.status) { return false; } if (!matchesDateRange(assignment.dueDate || assignment.startDate, state.filters.periodFrom, state.filters.periodTo)) { return false; } return matchesQuery(assignment, [zone ? zone.title : '', provider ? provider.name : ''].join(' ')); }).sort(compareBy('dueDate', 'asc')); } function selectFilteredTimesheets() { return state.data.timesheets.filter(function(timesheet) { var provider = getProviderById(timesheet.providerId); var assignment = getAssignmentById(timesheet.assignmentId); var zone = assignment ? getZoneById(assignment.zoneId) : null; if (state.filters.providerId && timesheet.providerId !== state.filters.providerId) { return false; } if (state.filters.buildingPart && zone && zone.buildingPart !== state.filters.buildingPart) { return false; } if (!matchesDateRange(timesheet.date, state.filters.periodFrom, state.filters.periodTo)) { return false; } return matchesQuery(timesheet, [provider ? provider.name : '', zone ? zone.title : ''].join(' ')); }).sort(compareBy('date', 'desc')); } function selectFilteredComplaints() { return state.data.complaints.filter(function(complaint) { var zone = getZoneById(complaint.zoneId); var provider = getProviderById(complaint.providerId); if (state.filters.providerId && complaint.providerId !== state.filters.providerId) { return false; } if (state.filters.status && complaint.status !== state.filters.status) { return false; } if (state.filters.buildingPart && zone && zone.buildingPart !== state.filters.buildingPart) { return false; } if (!matchesDateRange(complaint.dueDate || complaint.createdAt, state.filters.periodFrom, state.filters.periodTo)) { return false; } return matchesQuery(complaint, [provider ? provider.name : '', zone ? zone.title : ''].join(' ')); }).sort(compareBy('dueDate', 'asc')); } function selectFilteredMaterials() { return state.data.materials.filter(matchesMaterialFilters).sort(compareBy('name', 'asc')); } function selectFilteredMovements() { return state.data.stockMovements.filter(function(movement) { var material = getMaterialById(movement.materialId); if (!matchesDateRange(movement.date, state.filters.periodFrom, state.filters.periodTo)) { return false; } return matchesQuery(movement, material ? material.name : ''); }).sort(compareBy('date', 'desc')); } function selectFilteredMachines() { return state.data.machines.filter(function(machine) { var provider = getProviderById(machine.providerId); if (state.filters.providerId && machine.providerId !== state.filters.providerId) { return false; } return matchesQuery(machine, provider ? provider.name : ''); }).sort(compareBy('nextServiceDate', 'asc')); } function selectFilteredInvoices() { return state.data.invoices.filter(function(invoice) { var provider = getProviderById(invoice.providerId); var assignment = getAssignmentById(invoice.assignmentId); var zone = assignment ? getZoneById(assignment.zoneId) : null; if (state.filters.providerId && invoice.providerId !== state.filters.providerId) { return false; } if (state.filters.status && invoice.status !== state.filters.status) { return false; } if (state.filters.phase && assignment && assignment.phase !== state.filters.phase) { return false; } if (state.filters.buildingPart && zone && zone.buildingPart !== state.filters.buildingPart) { return false; } if (!matchesDateRange(invoice.date, state.filters.periodFrom, state.filters.periodTo)) { return false; } return matchesQuery(invoice, [provider ? provider.name : '', zone ? zone.title : '', assignment ? assignment.orderNo : ''].join(' ')); }).sort(compareBy('date', 'desc')); } function computeScheduleLamp(zone) { if (!zone || !zone.dueDate) { return { color: 'gray', text: 'Kein Termin' }; } if (zone.acceptanceStatus === 'freigegeben' || zone.status === 'freigegeben') { return { color: 'green', text: 'Freigegeben' }; } var days = daysDiff(todayIso(), zone.dueDate); if ((zone.status === 'in_arbeit' || zone.status === 'gereinigt' || zone.status === 'qs') && days >= 0) { return { color: 'green', text: 'Im Plan' }; } if (days >= 0 && days <= 2) { return { color: 'yellow', text: 'Knapp im Plan' }; } if (days < 0) { return { color: 'red', text: 'Verzug' }; } return { color: 'gray', text: 'Beobachten' }; } function computeQualityMetrics() { var inspections = state.data.inspections; var finished = inspections.filter(function(item) { return item.result === 'bestanden' || item.result === 'nacharbeit'; }); var firstPassPassed = inspections.filter(function(item) { return item.result === 'bestanden' && item.firstPass; }).length; var rework = inspections.filter(function(item) { return item.result === 'nacharbeit'; }).length; var passRate = finished.length ? round((firstPassPassed / finished.length) * 100, 1) : 0; return { finishedCount: finished.length, firstPassPassed: firstPassPassed, reworkCount: rework, passRate: passRate, averageScore: finished.length ? round(average(finished, 'score'), 1) : 0 }; } function computePhaseCosts() { var grouped = { grob: { sqm: 0, cost: 0 }, zwischen: { sqm: 0, cost: 0 }, fein: { sqm: 0, cost: 0 }, sicht: { sqm: 0, cost: 0 }, sonder: { sqm: 0, cost: 0 } }; state.data.assignments.forEach(function(assignment) { var phase = assignment.phase || 'grob'; if (!grouped[phase]) { grouped[phase] = { sqm: 0, cost: 0 }; } grouped[phase].sqm += toNumber(assignment.commissionedArea, 0); grouped[phase].cost += round(toNumber(assignment.commissionedArea, 0) * toNumber(assignment.ratePerSqm, 0), 2); }); return Object.keys(grouped).map(function(key) { return { phase: key, sqm: round(grouped[key].sqm, 2), cost: round(grouped[key].cost, 2), costPerSqm: grouped[key].sqm > 0 ? round(grouped[key].cost / grouped[key].sqm, 2) : 0 }; }); } function computeProviderPerformance(providerId) { var assignments = state.data.assignments.filter(function(item) { return item.providerId === providerId; }); var inspections = state.data.inspections.filter(function(item) { return item.providerId === providerId; }); var complaints = state.data.complaints.filter(function(item) { return item.providerId === providerId; }); var completed = assignments.filter(function(item) { return item.status === 'abgeschlossen' || item.status === 'in_pruefung' || item.status === 'reklamation' || item.status === 'nacharbeit'; }); var speedMinutesPerSqm = 0; if (completed.length) { var values = completed.map(function(item) { var area = Math.max(1, toNumber(item.measuredArea || item.commissionedArea, 0)); var minutes = Math.max(0, toNumber(item.actualMinutes, 0)); return minutes / area; }); speedMinutesPerSqm = round(average(values), 2); } var averageRating = inspections.length ? round(average(inspections, 'stars'), 1) : 0; var firstPassRate = inspections.length ? round((inspections.filter(function(item) { return item.result === 'bestanden' && item.firstPass; }).length / inspections.length) * 100, 1) : 0; var complaintRate = assignments.length ? round((complaints.length / assignments.length) * 100, 1) : 0; return { assignments: assignments.length, completedAssignments: completed.length, speedMinutesPerSqm: speedMinutesPerSqm, averageRating: averageRating, firstPassRate: firstPassRate, complaintRate: complaintRate, openComplaints: complaints.filter(function(item) { return item.status !== 'erledigt'; }).length }; } function computeInvoiceCheck(invoice) { var assignment = getAssignmentById(invoice.assignmentId); var baseArea = assignment ? toNumber(assignment.measuredArea || assignment.commissionedArea, 0) : 0; var billedArea = toNumber(invoice.billedArea, 0); var delta = round(billedArea - baseArea, 2); var deltaPct = baseArea > 0 ? round((delta / baseArea) * 100, 1) : 0; return { assignment: assignment, baseArea: baseArea, billedArea: billedArea, delta: delta, deltaPct: deltaPct, isOkay: Math.abs(deltaPct) <= 2 }; } function computeDashboardMetrics() { var zones = state.data.zones; var inspections = state.data.inspections; var defects = state.data.defects; var complaints = state.data.complaints; var assignments = state.data.assignments; var totalArea = round(sum(zones, 'totalArea'), 2); var cleanedArea = round(sum(zones, 'cleanedArea'), 2); var openArea = round(Math.max(0, totalArea - cleanedArea), 2); var progressPct = totalArea > 0 ? round((cleanedArea / totalArea) * 100, 1) : 0; var byBuildingPartMap = groupBy(zones, function(zone) { return zone.buildingPart || 'Ohne Gebäudeteil'; }); var byBuildingPart = Object.keys(byBuildingPartMap).map(function(key) { var list = byBuildingPartMap[key]; var partTotalArea = round(sum(list, 'totalArea'), 2); var partCleanedArea = round(sum(list, 'cleanedArea'), 2); var pct = partTotalArea > 0 ? round((partCleanedArea / partTotalArea) * 100, 1) : 0; return { buildingPart: key, totalArea: partTotalArea, cleanedArea: partCleanedArea, progressPct: pct, zones: list.length }; }).sort(function(a, b) { return b.progressPct - a.progressPct; }); var quality = computeQualityMetrics(); var phaseCosts = computePhaseCosts(); var providerPerformance = state.data.providers.map(function(provider) { var metrics = computeProviderPerformance(provider.id); return merge(provider, metrics); }).sort(function(a, b) { return b.averageRating - a.averageRating; }); var scheduleSummary = zones.reduce(function(acc, zone) { var lamp = computeScheduleLamp(zone); acc[lamp.color] = (acc[lamp.color] || 0) + 1; return acc; }, { green: 0, yellow: 0, red: 0, gray: 0 }); var nextDueZones = zones.slice().sort(compareBy('dueDate', 'asc')).filter(function(zone) { return zone.acceptanceStatus !== 'freigegeben'; }).slice(0, 6); var openDefects = defects.filter(function(item) { return item.status !== 'erledigt'; }); var openComplaints = complaints.filter(function(item) { return item.status !== 'erledigt'; }); return { totalArea: totalArea, cleanedArea: cleanedArea, openArea: openArea, progressPct: progressPct, byBuildingPart: byBuildingPart, quality: quality, phaseCosts: phaseCosts, providerPerformance: providerPerformance, openDefects: openDefects, openComplaints: openComplaints, nextDueZones: nextDueZones, scheduleSummary: scheduleSummary, zonesInDelay: zones.filter(function(zone) { return computeScheduleLamp(zone).color === 'red'; }).length, activeAssignments: assignments.filter(function(item) { return item.status === 'in_arbeit' || item.status === 'in_pruefung' || item.status === 'nacharbeit'; }).length, totalEstimateCost: round(sum(zones.map(estimateZoneCost)), 2) }; } function getDashboardMetrics() { if (!state.data.dashboard || !state.data.dashboard.byBuildingPart) { recalcDashboardSnapshot(); } return state.data.dashboard; } function computeReportRows(type) { if (type === 'fortschritt') { return state.data.zones.map(function(zone) { var provider = getProviderById(zone.providerId); return { building: zone.building, buildingPart: zone.buildingPart, floor: zone.floor, title: zone.title, phase: getLookupLabel(state.lookups.phases, zone.phase, zone.phase), totalArea: zone.totalArea, cleanedArea: zone.cleanedArea, progressPct: zone.progressPct, provider: provider ? provider.name : '—', dueDate: zone.dueDate, status: getLookupLabel(state.lookups.zoneStatuses, zone.status, zone.status) }; }); } if (type === 'abnahme') { return state.data.inspections.map(function(inspection) { var zone = getZoneById(inspection.zoneId); var provider = getProviderById(inspection.providerId); return { zone: zone ? zone.title : inspection.zoneId, buildingPart: zone ? zone.buildingPart : '', provider: provider ? provider.name : '', result: inspection.result, score: inspection.score, whiteGlovePassed: inspection.whiteGlovePassed ? 'Ja' : 'Nein', dustMeasurement: inspection.dustMeasurement, inspectedAt: inspection.inspectedAt, workflowStage: inspection.workflowStage }; }); } if (type === 'qualitaet') { return state.data.defects.map(function(defect) { var zone = getZoneById(defect.zoneId); var provider = getProviderById(defect.providerId); return { title: defect.title, zone: zone ? zone.title : defect.zoneId, buildingPart: zone ? zone.buildingPart : '', provider: provider ? provider.name : '', severity: defect.severity, dueDate: defect.dueDate, status: defect.status }; }); } if (type === 'kosten') { return state.data.assignments.map(function(assignment) { var zone = getZoneById(assignment.zoneId); var provider = getProviderById(assignment.providerId); var total = round(toNumber(assignment.commissionedArea, 0) * toNumber(assignment.ratePerSqm, 0), 2); return { orderNo: assignment.orderNo, zone: zone ? zone.title : assignment.zoneId, provider: provider ? provider.name : '', phase: assignment.phase, sqm: assignment.commissionedArea, ratePerSqm: assignment.ratePerSqm, total: total, status: assignment.status }; }); } if (type === 'dienstleister') { return state.data.providers.map(function(provider) { var perf = computeProviderPerformance(provider.id); return { provider: provider.name, assignments: perf.assignments, speedMinutesPerSqm: perf.speedMinutesPerSqm, averageRating: perf.averageRating, firstPassRate: perf.firstPassRate, complaintRate: perf.complaintRate, openComplaints: perf.openComplaints }; }); } if (type === 'excel') { return state.data.zones.map(function(zone) { return { code: zone.code, building: zone.building, buildingPart: zone.buildingPart, floor: zone.floor, room: zone.room, phase: zone.phase, totalArea: zone.totalArea, cleanedArea: zone.cleanedArea, progressPct: zone.progressPct, providerId: zone.providerId, dueDate: zone.dueDate, status: zone.status, acceptanceStatus: zone.acceptanceStatus }; }); } return []; } function renderOptionList(options, selectedValue, placeholder) { var html = ''; if (placeholder !== undefined) { html += ''; } safeArray(options).forEach(function(option) { var value = option.value != null ? option.value : option.id; var label = option.label != null ? option.label : option.name; var selected = String(value) === String(selectedValue) ? ' selected' : ''; html += ''; }); return html; } function renderProviderOptions(selectedValue, placeholder) { return renderOptionList(state.data.providers.map(function(provider) { return { value: provider.id, label: provider.name }; }), selectedValue, placeholder || 'Bitte wählen'); } function renderZoneOptions(selectedValue, placeholder) { return renderOptionList(state.data.zones.map(function(zone) { return { value: zone.id, label: zone.code + ' · ' + zone.title }; }), selectedValue, placeholder || 'Bitte wählen'); } function renderChecklistOptions(selectedValue, placeholder) { return renderOptionList(state.data.checklistTemplates.map(function(item) { return { value: item.id, label: item.title }; }), selectedValue, placeholder || 'Bitte wählen'); } function renderAssignmentOptions(selectedValue, placeholder) { return renderOptionList(state.data.assignments.map(function(item) { return { value: item.id, label: item.orderNo + ' · ' + (getZoneById(item.zoneId) ? getZoneById(item.zoneId).title : item.zoneId) }; }), selectedValue, placeholder || 'Bitte wählen'); } function renderMaterialOptions(selectedValue, placeholder) { return renderOptionList(state.data.materials.map(function(item) { return { value: item.id, label: item.name }; }), selectedValue, placeholder || 'Bitte wählen'); } function checkedAttr(value) { return value ? ' checked' : ''; } function selectedAttr(condition) { return condition ? ' selected' : ''; } function renderSectionHeader(title, actionsHtml) { return [ '
', '
', '
' + escapeHtml(title) + '
', '
', '
', actionsHtml || '', '
', '
' ].join(''); } function renderTextInput(config) { return [ '
', ' ', ' ', config.help ? '
' + escapeHtml(config.help) + '
' : '', '
' ].join(''); } function renderSelectInput(config) { return [ '
', ' ', ' ', config.help ? '
' + escapeHtml(config.help) + '
' : '', '
' ].join(''); } function renderTextarea(config) { return [ '
', ' ', ' ', config.help ? '
' + escapeHtml(config.help) + '
' : '', '
' ].join(''); } function renderCheckboxGroup(config) { var html = [ '
', ' ', '
' ]; safeArray(config.options).forEach(function(option) { var value = option.value != null ? option.value : option.id; var checked = safeArray(config.values).indexOf(value) !== -1; html.push( '
' + '
' + ' ' + ' ' + '
' + '
' ); }); html.push('
'); if (config.help) { html.push('
' + escapeHtml(config.help) + '
'); } html.push('
'); return html.join(''); } function renderSwitchInput(config) { return [ '
', '
', ' ', ' ', '
', config.help ? '
' + escapeHtml(config.help) + '
' : '', '
' ].join(''); } function renderPaginationControls(key, pagination) { if (!pagination || pagination.total <= pagination.pageSize) { return ''; } var prevDisabled = pagination.page <= 1 ? ' disabled' : ''; var nextDisabled = pagination.page >= pagination.totalPages ? ' disabled' : ''; return [ '
', '
Seite ' + escapeHtml(pagination.page) + ' von ' + escapeHtml(pagination.totalPages) + ' · ' + escapeHtml(pagination.total) + ' Einträge
', '
', ' ', ' ', '
', '
' ].join(''); } function renderRootSkeleton() { if (!state.root) { return; } state.root.innerHTML = [ '
', '
Daten werden geladen…
', '
', '
', '
', '

BauGenio v4 · Gebäudereinigung & Bauendreinigung

', '
Reinigungszonen, QS, Dienstleister, Materialien und Reports in einem Modul
', '
', '
', ' ', '
', '
', '
', '
', '
', '
' ].join(''); } function render() { if (!state.root) { return; } recalcDashboardSnapshot(); state.renderToken += 1; var content = state.root.querySelector('[data-role="content"]'); if (!content) { renderRootSkeleton(); content = state.root.querySelector('[data-role="content"]'); } content.innerHTML = [ renderToolbar(), renderBody(), renderPanel(), renderToastsPlaceholder() ].join(''); renderToasts(); } function renderToastsPlaceholder() { return ''; } function renderToolbar() { return [ '
', '
', '
', '
', '
', '

BauGenio v4 · Gebäudereinigung & Bauendreinigung

', '
Stand ' + escapeHtml(formatDateTime(state.lastSync || nowIso())) + '
', '
', '
', ' ', ' ', '
', '
', '
', '
', renderNavigation(), '
', '
', '
', renderFilterBar(), state.lastError ? '
API-Hinweis: ' + escapeHtml(state.lastError) + '
' : '', '
' ].join(''); } function renderNavigation() { return [ '
' ].concat(state.lookups.tabs.map(function(tab) { var active = state.activeTab === tab.id; return ' '; })).concat([ '
' ]).join(''); } function renderFilterBar() { return [ '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', ' ', '
', '
', ' ', '
', '
' ].join(''); } function renderBody() { return [ '
', '
', renderActiveTab(), '
', '
' ].join(''); } function renderActiveTab() { if (state.activeTab === 'dashboard') { return renderDashboardTab(); } if (state.activeTab === 'zonen') { return renderZonesTab(); } if (state.activeTab === 'leistungen') { return renderServicesTab(); } if (state.activeTab === 'qualitaet') { return renderQualityTab(); } if (state.activeTab === 'dienstleister') { return renderProvidersTab(); } if (state.activeTab === 'materialien') { return renderMaterialsTab(); } if (state.activeTab === 'berichte') { return renderReportsTab(); } return renderEmptyState('Kein Tab ausgewählt.'); } function renderDashboardTab() { var metrics = getDashboardMetrics(); var schedule = metrics.scheduleSummary || { green: 0, yellow: 0, red: 0, gray: 0 }; return [ '
', '
', renderKpiCard('Reinigungsfortschritt gesamt', formatPercent(metrics.progressPct), formatNumber(metrics.cleanedArea, 2) + ' / ' + formatNumber(metrics.totalArea, 2) + ' m² gereinigt'), '
', '
', renderKpiCard('Offene Fläche', formatNumber(metrics.openArea, 2) + ' m²', 'Noch nicht gereinigte oder nicht freigegebene Flächen'), '
', '
', renderKpiCard('Qualitätsquote Erstabnahme', formatPercent(metrics.quality.passRate), metrics.quality.firstPassPassed + ' bestanden · ' + metrics.quality.reworkCount + ' Nacharbeiten'), '
', '
', renderKpiCard('Gesamtkosten Schätzung', formatCurrency(metrics.totalEstimateCost), 'Ø Kosten aus beauftragten Raten / Katalogwerten'), '
', '
', '
', '
', '
', '
', ' Fortschritt nach Gebäudeteil', '
', ' ' + renderChip('Im Plan: ' + schedule.green, 'success'), ' ' + renderChip('Kritisch: ' + schedule.red, 'danger'), ' ' + renderChip('Beobachten: ' + schedule.yellow, 'warning'), '
', '
', '
', renderBuildingPartProgress(metrics.byBuildingPart), '
', '
', '
', '
', '
', '
', ' Offene Nacharbeiten & Reklamationen', ' ', '
', '
', renderOpenIssues(metrics.openDefects, metrics.openComplaints), '
', '
', '
', '
', '
', '
', '
', '
Kosten pro m² nach Reinigungsart
', '
', renderPhaseCostTable(metrics.phaseCosts), '
', '
', '
', '
', '
', '
', ' Dienstleister-Performance', ' ', '
', '
', renderProviderRanking(metrics.providerPerformance), '
', '
', '
', '
', '
', '
Nächste Fälligkeiten
', '
', renderNextDueZones(metrics.nextDueZones), '
', '
', '
', '
', '
', '
', '
', '
', ' Operative Verdichtung', '
Geschwindigkeit, Qualitäts- und Reklamationstreiber
', '
', '
', renderOperationalSummary(metrics), '
', '
', '
', '
' ].join(''); } function renderKpiCard(title, value, subline) { return [ '
', '
', '
' + escapeHtml(title) + '
', '
' + escapeHtml(value) + '
', '
' + escapeHtml(subline) + '
', '
', '
' ].join(''); } function renderBuildingPartProgress(list) { if (!list.length) { return renderEmptyState('Keine Gebäudeteile vorhanden.'); } return [ '
', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].concat(list.map(function(item) { return [ ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].join(''); })).concat([ ' ', '
GebäudeteilZonenFläche gesamtGereinigtFortschritt
' + escapeHtml(item.buildingPart) + '' + escapeHtml(item.zones) + '' + escapeHtml(formatNumber(item.totalArea, 2)) + ' m²' + escapeHtml(formatNumber(item.cleanedArea, 2)) + ' m²', '
', '
', ' ' + escapeHtml(formatPercent(item.progressPct)) + '', '
', '
', '
' ]).join(''); } function renderOpenIssues(defects, complaints) { var items = []; safeArray(defects).slice(0, 5).forEach(function(defect) { var zone = getZoneById(defect.zoneId); items.push( '
' + '
' + '
' + '
' + escapeHtml(defect.title) + '
' + '
' + escapeHtml(zone ? zone.title : defect.zoneId) + ' · Fällig ' + escapeHtml(formatDate(defect.dueDate)) + '
' + '
' + ' ' + renderChip(getLookupLabel(state.lookups.complaintSeverities, defect.severity, defect.severity), defect.severity === 'kritisch' ? 'danger' : defect.severity === 'hoch' ? 'warning' : 'neutral') + '
' + '
' ); }); safeArray(complaints).slice(0, 3).forEach(function(complaint) { var zone = getZoneById(complaint.zoneId); items.push( '
' + '
' + '
' + '
' + escapeHtml(complaint.title) + '
' + '
' + escapeHtml(zone ? zone.title : complaint.zoneId) + ' · Eskalation ' + escapeHtml(complaint.escalationLevel) + '
' + '
' + ' ' + renderChip(getLookupLabel(state.lookups.complaintSeverities, complaint.severity, complaint.severity), complaint.severity === 'kritisch' ? 'danger' : 'warning') + '
' + '
' ); }); if (!items.length) { return renderEmptyState('Keine offenen Nacharbeiten oder Reklamationen.'); } return '
' + items.join('') + '
'; } function renderPhaseCostTable(rows) { if (!rows.length) { return renderEmptyState('Keine Kostendaten vorhanden.'); } return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].concat(rows.map(function(row) { return [ ' ', ' ', ' ', ' ', ' ', ' ' ].join(''); })).concat([ ' ', '
ReinigungsartFlächeKosten€/m²
' + renderPhaseChip(row.phase) + '' + escapeHtml(formatNumber(row.sqm, 2)) + ' m²' + escapeHtml(formatCurrency(row.cost)) + '' + escapeHtml(formatCurrency(row.costPerSqm)) + '
' ]).join(''); } function renderProviderRanking(list) { if (!list.length) { return renderEmptyState('Keine Dienstleisterdaten vorhanden.'); } return [ '
' ].concat(list.slice(0, 5).map(function(item) { return [ '
', '
', '
', '
' + escapeHtml(item.name) + '
', '
Ø Geschwindigkeit ' + escapeHtml(formatNumber(item.speedMinutesPerSqm, 2)) + ' min/m² · Reklamationen ' + escapeHtml(formatPercent(item.complaintRate)) + '
', '
', '
', '
' + renderStars(item.averageRating) + '
', '
Erstabnahme ' + escapeHtml(formatPercent(item.firstPassRate)) + '
', '
', '
', '
' ].join(''); })).concat([ '
' ]).join(''); } function renderNextDueZones(list) { if (!list.length) { return renderEmptyState('Keine anstehenden Termine.'); } return [ '
' ].concat(list.map(function(zone) { var lamp = computeScheduleLamp(zone); var provider = getProviderById(zone.providerId); return [ '
', '
', '
', '
' + escapeHtml(zone.title) + '
', '
' + escapeHtml(zone.buildingPart) + ' · ' + escapeHtml(zone.floor) + ' · ' + escapeHtml(formatDate(zone.dueDate)) + '
', '
' + escapeHtml(provider ? provider.name : 'Kein Dienstleister') + ' · ' + getLookupLabel(state.lookups.zoneStatuses, zone.status, zone.status) + '
', '
', '
', ' ' + renderLamp(lamp.color) + ' ' + escapeHtml(lamp.text) + '', '
', '
', '
' ].join(''); })).concat([ '
' ]).join(''); } function renderOperationalSummary(metrics) { var bestProvider = metrics.providerPerformance[0]; var worstProvider = metrics.providerPerformance.slice().sort(function(a, b) { return b.complaintRate - a.complaintRate; })[0]; var criticalZone = safeArray(metrics.openDefects).sort(function(a, b) { var severityOrder = { kritisch: 4, hoch: 3, mittel: 2, niedrig: 1 }; return (severityOrder[b.severity] || 0) - (severityOrder[a.severity] || 0); })[0]; return [ '
', '
', '
Aktive Beauftragungen
', ' ' + escapeHtml(metrics.activeAssignments) + '', '
Laufend oder in Prüfung
', '
', '
', '
Zone im größten Risiko
', ' ' + escapeHtml(criticalZone ? criticalZone.title || criticalZone.id : '—') + '', '
' + escapeHtml(criticalZone ? criticalZone.description : 'Keine kritische Meldung') + '
', '
', '
', '
Stärkster Dienstleister
', ' ' + escapeHtml(bestProvider ? bestProvider.name : '—') + '', '
' + escapeHtml(bestProvider ? ('Rating ' + bestProvider.averageRating + ' / Reklamation ' + bestProvider.complaintRate + '%') : 'Keine Daten') + '
', '
', '
', '
Schwächster Reklamationstreiber
', ' ' + escapeHtml(worstProvider ? worstProvider.name : '—') + '', '
' + escapeHtml(worstProvider ? ('Reklamationsquote ' + worstProvider.complaintRate + '%') : 'Keine Daten') + '
', '
', '
', '
', '
', '
', ' Risikoblick', '
Unterkommissionierte Nacharbeit in sensiblen Bereichen frisst Marge. Besonders Reinraum, Sanitär und Küche sind aktuell die operativen Kostentreiber.
', '
', '
', '
', '
', ' Absicherung', '
Rechnungsfreigaben an QS-Freigabe koppeln, Nacharbeit binnen 24h vertraglich ziehen und Flächenabweichungen > 2 % automatisch sperren.
', '
', '
', '
' ].join(''); } function renderMaterialsTab() { var materials = selectFilteredMaterials(); var movements = selectFilteredMovements(); var machines = selectFilteredMachines(); var pagination = paginate(materials, state.pagination.materials); return [ '
', '
', '
', '
', '
', ' Reinigungsmittel & Materialien', '
Katalog, Sicherheitsdatenblätter, Gefahrstoffe, Zertifizierungen, Verbrauch und Bestandsführung
', '
', '
', ' ', ' ', ' ', '
', '
', '
', renderMaterialsSummary(materials), '
', renderMaterialsTable(pagination.items), '
', renderPaginationControls('materials', pagination), '
', '
', '
', '
', '
', '
', '
', '
Bestandsbewegungen
', '
', renderMovementsTable(movements), '
', '
', '
', '
', '
', '
Maschineneinsatz
', '
', renderMachinesTable(machines), '
', '
', '
', '
' ].join(''); } function renderMaterialsSummary(materials) { var lowStock = materials.filter(function(item) { return toNumber(item.stock, 0) < toNumber(item.minStock, 0); }).length; var hazardous = materials.filter(function(item) { return item.hazardClass && item.hazardClass !== 'kein'; }).length; var ecoCertified = materials.filter(function(item) { return safeArray(item.ecoCertifications).length > 0; }).length; return [ '
', '
Materialien im Filter
' + escapeHtml(materials.length) + '
Aktive Stammdaten
', '
Unterbestand
' + escapeHtml(lowStock) + '
Sofort disponieren
', '
Gefahrstoffe
' + escapeHtml(hazardous) + '
SDS / Handling prüfen
', '
Umweltzertifiziert
' + escapeHtml(ecoCertified) + '
EU Ecolabel / Blauer Engel etc.
', '
' ].join(''); } function renderMaterialsTable(materials) { if (!materials.length) { return renderEmptyState('Keine Materialien vorhanden.'); } return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].concat(materials.map(function(item) { var low = toNumber(item.stock, 0) < toNumber(item.minStock, 0); return [ ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].join(''); })).concat([ ' ', '
MaterialBestandMin.GefahrstoffZertifizierungSDSAktionen
', '
' + escapeHtml(item.name) + '
', '
' + escapeHtml(item.category) + ' · ' + escapeHtml(getMultipleLookupLabels(state.lookups.surfaceTypes, item.applicationSurfaceTypes).join(', ')) + '
', '
' + (low ? renderChip(formatNumber(item.stock, 2) + ' ' + item.unit, 'danger') : renderChip(formatNumber(item.stock, 2) + ' ' + item.unit, 'success')) + '' + escapeHtml(formatNumber(item.minStock, 2)) + ' ' + escapeHtml(item.unit) + '' + renderChip(getLookupLabel(state.lookups.hazardClasses, item.hazardClass, item.hazardClass), item.hazardClass === 'kein' ? 'success' : 'warning') + '' + (item.ecoCertifications.length ? item.ecoCertifications.map(function(cert) { return renderChip(getLookupLabel(state.lookups.ecoCertifications, cert, cert), 'accent'); }).join('') : renderChip('Keine', 'neutral')) + '' + (item.sdsUrl ? 'SDS öffnen' : '—') + '', '
', ' ', ' ', ' ', '
', '
' ]).join(''); } function renderMovementsTable(movements) { if (!movements.length) { return renderEmptyState('Keine Bestandsbewegungen vorhanden.'); } return [ '', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].concat(movements.map(function(item) { var material = getMaterialById(item.materialId); return [ ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ' ].join(''); })).concat([ ' ', '
DatumMaterialTypMengeNotizAktionen
' + escapeHtml(formatDate(item.date)) + '' + escapeHtml(material ? material.name : item.materialId) + '' + renderChip(getLookupLabel(state.lookups.stockMovementTypes, item.type, item.type), item.type === 'verbrauch' ? 'warning' : 'accent') + '' + escapeHtml(formatNumber(item.quantity, 2)) + ' ' + escapeHtml(material ? material.unit : '') + '' + escapeHtml(item.notes) + '', '
', ' ', ' ', '
', '
' ]).join(''); } function renderMachinesTable(machines) { if (!machines.length) { return renderEmptyState('Keine Maschinen vorhanden.'); } return '
' + machines.map(function(item) { var provider = getProviderById(item.providerId); var serviceDue = item.nextServiceDate && daysDiff(todayIso(), item.nextServiceDate) > 0; return [ '
', '
', '
', '
' + escapeHtml(item.name) + '
', '
' + escapeHtml(getLookupLabel(state.lookups.machineTypes, item.type, item.type)) + ' · SN ' + escapeHtml(item.serialNo) + '
', '
Zugeordnet: ' + escapeHtml(provider ? provider.name : 'intern') + '
', '
Wartung fällig: ' + escapeHtml(formatDate(item.nextServiceDate)) + '
', '
', ' ' + renderChip(item.status, serviceDue ? 'danger' : 'neutral') + '
', '
', ' ', ' ', '
', '
' ].join(''); }).join('') + '
'; } function renderReportsTab() { var reportType = state.ui.activeReportType || 'fortschritt'; var preview = state.ui.activeReportPreview || buildReportPreview(reportType); var reports = state.data.reports.slice().sort(compareBy('generatedAt', 'desc')); var pagination = paginate(reports, state.pagination.reports); return [ '
', '
', '
', '
Berichte & Exporte
', '
', renderReportActionCards(reportType), '
', '
', '
', '
', '
', '
', '
', ' Vorschau', '
' + escapeHtml(getLookupLabel(state.lookups.reportTypes, reportType, reportType)) + '
', '
', '
', ' ', ' ', ' ', '
', '
', '
', renderReportPreview(preview), '
', '
', '
', '
', '
', '
', '
', '
Report-Historie
', '
', renderReportHistory(pagination.items), renderPaginationControls('reports', pagination), '
', '
', '
', '
' ].join(''); } function renderReportActionCards(activeType) { return '
' + state.lookups.reportTypes.map(function(type) { var isActive = type.value === activeType; return [ '
', '
', '
', '
' + escapeHtml(type.label) + '
', '
' + escapeHtml(getReportDescription(type.value)) + '
', '
', ' ' + renderChip(isActive ? 'Aktiv' : 'wählen', isActive ? 'success' : 'neutral') + '
', '
', ' ', ' ', '
', '
' ].join(''); }).join('') + '
'; } function getReportDescription(type) { var descriptions = { fortschritt: 'Flächen, Fortschritt, Status und Terminlage je Gebäudeteil', abnahme: 'Abnahmeprotokolle, QS-Status und Fotodokumentation', qualitaet: 'Mängel, Nacharbeit, White-Glove-Test und Erstabnahmequote', kosten: 'Beauftragte Fläche, €/m², Gesamtkosten und Rechnungsstatus', dienstleister: 'Bewertung, Geschwindigkeit, Reklamationsquote und Sterne', excel: 'Gesamtexport aller Flächen, Abnahmen und Kosten in Tabellenformat' }; return descriptions[type] || 'Berichtsvorschau'; } function buildReportPreview(type) { var rows = computeReportRows(type); var report = { id: uid('report'), type: type, title: getLookupLabel(state.lookups.reportTypes, type, type), generatedAt: nowIso(), summary: 'Vorschau auf Basis des aktuellen Filters.', rows: rows }; return report; } function renderReportPreview(report) { if (!report) { return renderEmptyState('Kein Bericht ausgewählt.'); } var rows = safeArray(report.rows); return [ '
', '
', '
', '
' + escapeHtml(report.title) + '
', '
Generiert: ' + escapeHtml(formatDateTime(report.generatedAt)) + '
', '
', ' ' + renderChip(rows.length + ' Datensätze', 'accent') + '
', '
' + escapeHtml(report.summary || '') + '
', '
', rows.length ? renderPreviewTable(rows) : renderEmptyState('Keine Daten für diesen Bericht im aktuellen Filter.') ].join(''); } function renderPreviewTable(rows) { var keys = Object.keys(rows[0] || {}); return [ '
', ' ', ' ', ' ' ].concat(keys.map(function(key) { return ' '; })).concat([ ' ', ' ', ' ' ]).concat(rows.slice(0, 25).map(function(row) { return [ ' ' ].concat(keys.map(function(key) { return ' '; })).concat([ ' ' ]).join(''); })).concat([ ' ', '
' + escapeHtml(key) + '
' + escapeHtml(row[key] == null ? '' : String(row[key])) + '
', rows.length > 25 ? '
Vorschau gekürzt. Vollständiger Export via PDF / Excel.
' : '', '
' ]).join(''); } function renderReportHistory(reports) { if (!reports.length) { return renderEmptyState('Noch keine Reports erzeugt.'); } return '
' + reports.map(function(report) { return [ '
', '
', '
', '
' + escapeHtml(report.title) + '
', '
' + escapeHtml(formatDateTime(report.generatedAt)) + ' · ' + escapeHtml(report.summary || '') + '
', '
', ' ' + renderChip(getLookupLabel(state.lookups.reportTypes, report.type, report.type), 'accent') + '
', '
', ' ', ' ', ' ', ' ', '
', '
' ].join(''); }).join('') + '
'; } function renderPanel() { if (!state.activePanel) { return ''; } var panelName = state.activePanel.name; if (panelName === 'zone') { return renderZonePanel(); } if (panelName === 'service') { return renderServicePanel(); } if (panelName === 'checklist') { return renderChecklistPanel(); } if (panelName === 'inspection') { return renderInspectionPanel(); } if (panelName === 'defect') { return renderDefectPanel(); } if (panelName === 'complaint') { return renderComplaintPanel(); } if (panelName === 'provider') { return renderProviderPanel(); } if (panelName === 'assignment') { return renderAssignmentPanel(); } if (panelName === 'timesheet') { return renderTimesheetPanel(); } if (panelName === 'material') { return renderMaterialPanel(); } if (panelName === 'movement') { return renderMovementPanel(); } if (panelName === 'machine') { return renderMachinePanel(); } if (panelName === 'invoice') { return renderInvoicePanel(); } return ''; } function renderPanelScaffold(title, formType, innerHtml) { return [ '
', '' ].join(''); } function renderZonePanel() { var zone = state.forms.zone || createEmptyZone(); return renderPanelScaffold((zone.id && recordExists('zones', zone.id) ? 'Zone bearbeiten' : 'Zone anlegen'), 'zone', [ '', '
', '
Stammdaten
', '
', renderTextInput({ name: 'code', label: 'Zonencode', value: zone.code, required: true, col: 'col-12 col-md-4' }), renderTextInput({ name: 'building', label: 'Gebäude', value: zone.building, required: true, col: 'col-12 col-md-4' }), renderTextInput({ name: 'buildingPart', label: 'Gebäudeteil', value: zone.buildingPart, required: true, col: 'col-12 col-md-4' }), renderTextInput({ name: 'floor', label: 'Geschoss', value: zone.floor, required: true, col: 'col-12 col-md-4' }), renderTextInput({ name: 'room', label: 'Raum', value: zone.room, required: true, col: 'col-12 col-md-4' }), renderTextInput({ name: 'title', label: 'Titel', value: zone.title, required: true, col: 'col-12 col-md-4' }), renderSelectInput({ name: 'usageType', label: 'Nutzungsart', optionsHtml: renderOptionList(state.lookups.usageTypes, zone.usageType), col: 'col-12 col-md-4' }), renderSelectInput({ name: 'phase', label: 'Reinigungsphase', optionsHtml: renderOptionList(state.lookups.phases, zone.phase), col: 'col-12 col-md-4' }), renderSelectInput({ name: 'status', label: 'Status', optionsHtml: renderOptionList(state.lookups.zoneStatuses, zone.status), col: 'col-12 col-md-4' }), renderSelectInput({ name: 'acceptanceStatus', label: 'Abnahmestatus', optionsHtml: renderOptionList(state.lookups.acceptanceStatuses, zone.acceptanceStatus), col: 'col-12 col-md-4' }), renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(zone.providerId, 'Bitte wählen'), col: 'col-12 col-md-8' }), '
', '
', '
', '
Flächenaufmaß
', '
', renderTextInput({ name: 'length', label: 'Länge (m)', type: 'number', step: '0.01', value: zone.length, col: 'col-12 col-md-4' }), renderTextInput({ name: 'width', label: 'Breite (m)', type: 'number', step: '0.01', value: zone.width, col: 'col-12 col-md-4' }), renderTextInput({ name: 'height', label: 'Höhe (m)', type: 'number', step: '0.01', value: zone.height, col: 'col-12 col-md-4' }), renderTextInput({ name: 'manualWindowArea', label: 'Fensterfläche manuell (m²)', type: 'number', step: '0.01', value: zone.manualWindowArea, col: 'col-12 col-md-6' }), renderTextInput({ name: 'manualFacadeArea', label: 'Fassadenfläche manuell (m²)', type: 'number', step: '0.01', value: zone.manualFacadeArea, col: 'col-12 col-md-6' }), renderTextInput({ name: 'cleanedArea', label: 'Bereits gereinigt (m²)', type: 'number', step: '0.01', value: zone.cleanedArea, col: 'col-12 col-md-6', help: 'Für Fortschrittsgrad / Teilfertigstellungen.' }), renderSelectInput({ name: 'dirtGrade', label: 'Verschmutzungsgrad', optionsHtml: renderOptionList(state.lookups.dirtGrades, zone.dirtGrade), col: 'col-12 col-md-6' }), renderCheckboxGroup({ name: 'surfaces', label: 'Aktive Flächentypen', options: state.lookups.surfaceTypes, values: zone.surfaces, col: 'col-12', itemCol: 'col-12 col-md-6' }), '
', renderZoneAreaPreview(zone), '
', '
', '
Anforderungen & Dokumentation
', '
', renderCheckboxGroup({ name: 'specialRequirements', label: 'Besondere Anforderungen', options: state.lookups.specialRequirements, values: zone.specialRequirements, col: 'col-12', itemCol: 'col-12 col-md-6' }), renderCheckboxGroup({ name: 'accessRequirements', label: 'Zugangsvoraussetzungen', options: state.lookups.accessRequirements, values: zone.accessRequirements, col: 'col-12', itemCol: 'col-12 col-md-6' }), renderTextInput({ name: 'dueDate', label: 'Fälligkeitsdatum', type: 'date', value: zone.dueDate, col: 'col-12 col-md-6' }), renderSwitchInput({ name: 'whiteGloveTest', label: 'White-Glove-Test vorgesehen', value: zone.whiteGloveTest, col: 'col-12 col-md-6' }), renderTextInput({ name: 'dustMeasurement', label: 'Feinstaubmessung (µg/m³)', type: 'number', step: '1', value: zone.dustMeasurement, col: 'col-12 col-md-6' }), renderTextarea({ name: 'photodocBefore', label: 'Fotodokumentation vorher', value: stringifyList(zone.photodocBefore), rows: 3, col: 'col-12 col-md-6', placeholder: 'Je Zeile eine URL oder Dateireferenz' }), renderTextarea({ name: 'photodocAfter', label: 'Fotodokumentation nachher', value: stringifyList(zone.photodocAfter), rows: 3, col: 'col-12 col-md-6', placeholder: 'Je Zeile eine URL oder Dateireferenz' }), renderTextarea({ name: 'notes', label: 'Hinweise', value: zone.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderZoneAreaPreview(zone) { var metrics = calculateZoneAreas(zone); return [ '
', ' Automatische Flächenberechnung', '
', '
Boden
' + escapeHtml(formatNumber(metrics.floorArea, 2)) + ' m²
', '
Wand
' + escapeHtml(formatNumber(metrics.wallArea, 2)) + ' m²
', '
Decke
' + escapeHtml(formatNumber(metrics.ceilingArea, 2)) + ' m²
', '
Fenster
' + escapeHtml(formatNumber(metrics.windowArea, 2)) + ' m²
', '
Fassade
' + escapeHtml(formatNumber(metrics.facadeArea, 2)) + ' m²
', '
Gesamt aktiv
' + escapeHtml(formatNumber(metrics.totalArea, 2)) + ' m²
', '
', '
' ].join(''); } function renderServicePanel() { var service = state.forms.service || createEmptyService(); return renderPanelScaffold((service.id && recordExists('serviceCatalog', service.id) ? 'Leistung bearbeiten' : 'Leistung anlegen'), 'service', [ '', '
', '
Leistungsdaten
', '
', renderTextInput({ name: 'title', label: 'Titel', value: service.title, required: true, col: 'col-12' }), renderSelectInput({ name: 'phase', label: 'Phase', optionsHtml: renderOptionList(state.lookups.phases, service.phase), col: 'col-12 col-md-4' }), renderSelectInput({ name: 'surfaceType', label: 'Oberflächentyp', optionsHtml: renderOptionList(state.lookups.surfaceTypes, service.surfaceType), col: 'col-12 col-md-4' }), renderSelectInput({ name: 'dirtGrade', label: 'Verschmutzungsgrad', optionsHtml: renderOptionList(state.lookups.dirtGrades, service.dirtGrade), col: 'col-12 col-md-4' }), renderTextInput({ name: 'minutesPerSqm', label: 'Minuten je m²', type: 'number', step: '0.01', value: service.minutesPerSqm, required: true, col: 'col-12 col-md-6' }), renderTextInput({ name: 'costPerSqm', label: 'Kosten je m²', type: 'number', step: '0.01', value: service.costPerSqm, required: true, col: 'col-12 col-md-6' }), renderSwitchInput({ name: 'specialCleaning', label: 'Sonderreinigung', value: service.specialCleaning, col: 'col-12 col-md-6' }), renderSwitchInput({ name: 'active', label: 'Aktiv', value: service.active, col: 'col-12 col-md-6' }), renderTextarea({ name: 'description', label: 'Beschreibung', value: service.description, rows: 5, col: 'col-12' }), '
', '
' ].join('')); } function renderChecklistPanel() { var checklist = state.forms.checklist || createEmptyChecklist(); return renderPanelScaffold((checklist.id && recordExists('checklistTemplates', checklist.id) ? 'Checkliste bearbeiten' : 'Checkliste anlegen'), 'checklist', [ '', '
', '
Checklisten-Konfiguration
', '
', renderTextInput({ name: 'title', label: 'Titel', value: checklist.title, required: true, col: 'col-12' }), renderSelectInput({ name: 'usageType', label: 'Nutzungstyp', optionsHtml: renderOptionList(state.lookups.usageTypes, checklist.usageType), col: 'col-12 col-md-6' }), renderSwitchInput({ name: 'active', label: 'Aktiv', value: checklist.active, col: 'col-12 col-md-6' }), renderCheckboxGroup({ name: 'criteria', label: 'Prüfkriterien', options: state.lookups.checklistCriteria, values: checklist.criteria, col: 'col-12', itemCol: 'col-12 col-md-6' }), renderTextarea({ name: 'notes', label: 'Hinweise', value: checklist.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderInspectionPanel() { var inspection = state.forms.inspection || createEmptyInspection(); return renderPanelScaffold((inspection.id && recordExists('inspections', inspection.id) ? 'Abnahme bearbeiten' : 'Abnahme erfassen'), 'inspection', [ '', '
', '
Abnahme
', '
', renderSelectInput({ name: 'zoneId', label: 'Zone', optionsHtml: renderZoneOptions(inspection.zoneId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(inspection.providerId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'checklistTemplateId', label: 'Checkliste', optionsHtml: renderChecklistOptions(inspection.checklistTemplateId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'result', label: 'Ergebnis', optionsHtml: renderOptionList(state.lookups.acceptanceStatuses, inspection.result), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'workflowStage', label: 'Workflow-Stage', optionsHtml: renderOptionList([{ value: 'reinigung', label: 'Reinigung' }, { value: 'qs', label: 'QS-Prüfung' }, { value: 'bauleiter', label: 'Bauleiter' }, { value: 'freigabe', label: 'Freigabe' }, { value: 'nacharbeit', label: 'Nacharbeit' }], inspection.workflowStage), col: 'col-12 col-md-6' }), renderTextInput({ name: 'inspectedAt', label: 'Prüfzeitpunkt', type: 'datetime-local', value: inspection.inspectedAt ? inspection.inspectedAt.slice(0, 16) : '', col: 'col-12 col-md-6' }), renderTextInput({ name: 'inspector', label: 'Prüfer', value: inspection.inspector, col: 'col-12 col-md-6' }), renderTextInput({ name: 'score', label: 'Score (0-100)', type: 'number', step: '1', min: '0', max: '100', value: inspection.score, col: 'col-12 col-md-6' }), renderTextInput({ name: 'stars', label: 'Sterne (0-5)', type: 'number', step: '1', min: '0', max: '5', value: inspection.stars, col: 'col-12 col-md-6' }), renderSwitchInput({ name: 'whiteGlovePassed', label: 'White-Glove-Test bestanden', value: inspection.whiteGlovePassed, col: 'col-12 col-md-6' }), renderSwitchInput({ name: 'firstPass', label: 'Erstabnahme', value: inspection.firstPass, col: 'col-12 col-md-6' }), renderTextInput({ name: 'dustMeasurement', label: 'Feinstaubmessung (µg/m³)', type: 'number', step: '1', value: inspection.dustMeasurement, col: 'col-12 col-md-6' }), renderTextInput({ name: 'nextDueDate', label: 'Nachfrist / nächste Prüfung', type: 'date', value: inspection.nextDueDate, col: 'col-12 col-md-6' }), renderTextarea({ name: 'beforePhotos', label: 'Fotos vorher', value: stringifyList(inspection.beforePhotos), rows: 3, col: 'col-12 col-md-6' }), renderTextarea({ name: 'afterPhotos', label: 'Fotos nachher', value: stringifyList(inspection.afterPhotos), rows: 3, col: 'col-12 col-md-6' }), renderTextarea({ name: 'findings', label: 'Feststellungen', value: inspection.findings, rows: 5, col: 'col-12' }), '
', '
' ].join('')); } function renderDefectPanel() { var defect = state.forms.defect || createEmptyDefect(); return renderPanelScaffold((defect.id && recordExists('defects', defect.id) ? 'Mangel bearbeiten' : 'Mangel erfassen'), 'defect', [ '', '
', '
Mangel / Nacharbeit
', '
', renderTextInput({ name: 'title', label: 'Titel', value: defect.title, required: true, col: 'col-12' }), renderSelectInput({ name: 'zoneId', label: 'Zone', optionsHtml: renderZoneOptions(defect.zoneId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'inspectionId', label: 'Abnahme', optionsHtml: renderOptionList(state.data.inspections.map(function(item) { return { value: item.id, label: item.id + ' · ' + (getZoneById(item.zoneId) ? getZoneById(item.zoneId).title : item.zoneId) }; }), defect.inspectionId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(defect.providerId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'severity', label: 'Schweregrad', optionsHtml: renderOptionList(state.lookups.complaintSeverities, defect.severity), col: 'col-12 col-md-6' }), renderTextInput({ name: 'dueDate', label: 'Frist', type: 'date', value: defect.dueDate, col: 'col-12 col-md-6' }), renderSelectInput({ name: 'status', label: 'Status', optionsHtml: renderOptionList([{ value: 'offen', label: 'Offen' }, { value: 'in_bearbeitung', label: 'In Bearbeitung' }, { value: 'erledigt', label: 'Erledigt' }], defect.status), col: 'col-12 col-md-6' }), renderTextarea({ name: 'description', label: 'Beschreibung', value: defect.description, rows: 5, col: 'col-12' }), '
', '
' ].join('')); } function renderComplaintPanel() { var complaint = state.forms.complaint || createEmptyComplaint(); return renderPanelScaffold((complaint.id && recordExists('complaints', complaint.id) ? 'Reklamation bearbeiten' : 'Reklamation erfassen'), 'complaint', [ '', '
', '
Reklamation
', '
', renderTextInput({ name: 'title', label: 'Titel', value: complaint.title, required: true, col: 'col-12' }), renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(complaint.providerId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'zoneId', label: 'Zone', optionsHtml: renderZoneOptions(complaint.zoneId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'inspectionId', label: 'Abnahme', optionsHtml: renderOptionList(state.data.inspections.map(function(item) { return { value: item.id, label: item.id + ' · ' + (getZoneById(item.zoneId) ? getZoneById(item.zoneId).title : item.zoneId) }; }), complaint.inspectionId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'severity', label: 'Schweregrad', optionsHtml: renderOptionList(state.lookups.complaintSeverities, complaint.severity), col: 'col-12 col-md-6' }), renderTextInput({ name: 'escalationLevel', label: 'Eskalationsstufe', type: 'number', step: '1', min: '1', value: complaint.escalationLevel, col: 'col-12 col-md-6' }), renderSelectInput({ name: 'status', label: 'Status', optionsHtml: renderOptionList([{ value: 'offen', label: 'Offen' }, { value: 'in_bearbeitung', label: 'In Bearbeitung' }, { value: 'erledigt', label: 'Erledigt' }], complaint.status), col: 'col-12 col-md-6' }), renderTextInput({ name: 'dueDate', label: 'Frist', type: 'date', value: complaint.dueDate, col: 'col-12 col-md-6' }), renderTextarea({ name: 'description', label: 'Beschreibung', value: complaint.description, rows: 5, col: 'col-12' }), '
', '
' ].join('')); } function renderProviderPanel() { var provider = state.forms.provider || createEmptyProvider(); return renderPanelScaffold((provider.id && recordExists('providers', provider.id) ? 'Dienstleister bearbeiten' : 'Dienstleister anlegen'), 'provider', [ '', '
', '
Stammdaten
', '
', renderTextInput({ name: 'name', label: 'Firmenname', value: provider.name, required: true, col: 'col-12' }), renderTextInput({ name: 'contactPerson', label: 'Ansprechpartner', value: provider.contactPerson, col: 'col-12 col-md-6' }), renderTextInput({ name: 'email', label: 'E-Mail', value: provider.email, type: 'email', col: 'col-12 col-md-6' }), renderTextInput({ name: 'phone', label: 'Telefon', value: provider.phone, col: 'col-12 col-md-6' }), renderTextInput({ name: 'frameworkContract', label: 'Rahmenvertrag', value: provider.frameworkContract, col: 'col-12 col-md-6' }), renderTextInput({ name: 'responseHours', label: 'Reaktionszeit (h)', type: 'number', step: '1', value: provider.responseHours, col: 'col-12 col-md-6' }), renderTextInput({ name: 'rateGrob', label: 'Rate Grob €/m²', type: 'number', step: '0.01', value: provider.rateGrob, col: 'col-12 col-md-3' }), renderTextInput({ name: 'rateZwischen', label: 'Rate Zwischen €/m²', type: 'number', step: '0.01', value: provider.rateZwischen, col: 'col-12 col-md-3' }), renderTextInput({ name: 'rateFein', label: 'Rate Fein €/m²', type: 'number', step: '0.01', value: provider.rateFein, col: 'col-12 col-md-3' }), renderTextInput({ name: 'rateSicht', label: 'Rate Sicht €/m²', type: 'number', step: '0.01', value: provider.rateSicht, col: 'col-12 col-md-3' }), renderSwitchInput({ name: 'active', label: 'Aktiv', value: provider.active, col: 'col-12 col-md-6' }), renderTextarea({ name: 'notes', label: 'Notizen', value: provider.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderAssignmentPanel() { var assignment = state.forms.assignment || createEmptyAssignment(); return renderPanelScaffold((assignment.id && recordExists('assignments', assignment.id) ? 'Beauftragung bearbeiten' : 'Beauftragung anlegen'), 'assignment', [ '', '
', '
Auftrag
', '
', renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(assignment.providerId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'zoneId', label: 'Zone', optionsHtml: renderZoneOptions(assignment.zoneId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'phase', label: 'Phase', optionsHtml: renderOptionList(state.lookups.phases, assignment.phase), col: 'col-12 col-md-4' }), renderTextInput({ name: 'orderNo', label: 'Auftragsnummer', value: assignment.orderNo, col: 'col-12 col-md-4' }), renderSelectInput({ name: 'status', label: 'Status', optionsHtml: renderOptionList([{ value: 'beauftragt', label: 'Beauftragt' }, { value: 'in_arbeit', label: 'In Arbeit' }, { value: 'in_pruefung', label: 'In Prüfung' }, { value: 'abgeschlossen', label: 'Abgeschlossen' }, { value: 'nacharbeit', label: 'Nacharbeit' }, { value: 'reklamation', label: 'Reklamation' }], assignment.status), col: 'col-12 col-md-4' }), renderTextInput({ name: 'startDate', label: 'Start', type: 'date', value: assignment.startDate, col: 'col-12 col-md-4' }), renderTextInput({ name: 'dueDate', label: 'Fällig', type: 'date', value: assignment.dueDate, col: 'col-12 col-md-4' }), renderTextInput({ name: 'commissionedArea', label: 'Beauftragte Fläche (m²)', type: 'number', step: '0.01', value: assignment.commissionedArea, col: 'col-12 col-md-4' }), renderTextInput({ name: 'measuredArea', label: 'Gemessene Fläche (m²)', type: 'number', step: '0.01', value: assignment.measuredArea, col: 'col-12 col-md-4' }), renderTextInput({ name: 'ratePerSqm', label: 'Rate €/m²', type: 'number', step: '0.01', value: assignment.ratePerSqm, col: 'col-12 col-md-4' }), renderTextInput({ name: 'crewSize', label: 'Kolonnenstärke', type: 'number', step: '1', value: assignment.crewSize, col: 'col-12 col-md-4' }), renderTextInput({ name: 'commissionedMinutes', label: 'Soll-Minuten', type: 'number', step: '1', value: assignment.commissionedMinutes, col: 'col-12 col-md-4' }), renderTextInput({ name: 'actualMinutes', label: 'Ist-Minuten', type: 'number', step: '1', value: assignment.actualMinutes, col: 'col-12 col-md-4' }), renderTextInput({ name: 'qualityRating', label: 'Leistungsbewertung Sterne', type: 'number', step: '1', min: '0', max: '5', value: assignment.qualityRating, col: 'col-12 col-md-4' }), renderTextarea({ name: 'notes', label: 'Notizen', value: assignment.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderTimesheetPanel() { var timesheet = state.forms.timesheet || createEmptyTimesheet(); return renderPanelScaffold((timesheet.id && recordExists('timesheets', timesheet.id) ? 'Stundenzettel bearbeiten' : 'Stundenzettel erfassen'), 'timesheet', [ '', '
', '
Stundenzettel
', '
', renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(timesheet.providerId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'assignmentId', label: 'Auftrag', optionsHtml: renderAssignmentOptions(timesheet.assignmentId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderTextInput({ name: 'date', label: 'Datum', type: 'date', value: timesheet.date, col: 'col-12 col-md-4' }), renderTextInput({ name: 'staffCount', label: 'Personalanzahl', type: 'number', step: '1', value: timesheet.staffCount, col: 'col-12 col-md-4' }), renderTextInput({ name: 'hours', label: 'Stunden', type: 'number', step: '0.25', value: timesheet.hours, col: 'col-12 col-md-4' }), renderSwitchInput({ name: 'approved', label: 'Freigegeben', value: timesheet.approved, col: 'col-12 col-md-6' }), renderTextarea({ name: 'notes', label: 'Notizen', value: timesheet.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderMaterialPanel() { var material = state.forms.material || createEmptyMaterial(); return renderPanelScaffold((material.id && recordExists('materials', material.id) ? 'Material bearbeiten' : 'Material anlegen'), 'material', [ '', '
', '
Materialstamm
', '
', renderTextInput({ name: 'name', label: 'Materialname', value: material.name, required: true, col: 'col-12' }), renderTextInput({ name: 'category', label: 'Kategorie', value: material.category, col: 'col-12 col-md-6' }), renderTextInput({ name: 'unit', label: 'Einheit', value: material.unit, col: 'col-12 col-md-2' }), renderTextInput({ name: 'stock', label: 'Bestand', type: 'number', step: '0.01', value: material.stock, col: 'col-12 col-md-2' }), renderTextInput({ name: 'minStock', label: 'Mindestbestand', type: 'number', step: '0.01', value: material.minStock, col: 'col-12 col-md-2' }), renderSelectInput({ name: 'hazardClass', label: 'Gefahrstoffklasse', optionsHtml: renderOptionList(state.lookups.hazardClasses, material.hazardClass), col: 'col-12 col-md-6' }), renderTextInput({ name: 'sdsUrl', label: 'Sicherheitsdatenblatt URL', value: material.sdsUrl, col: 'col-12 col-md-6' }), renderCheckboxGroup({ name: 'applicationSurfaceTypes', label: 'Oberflächen', options: state.lookups.surfaceTypes, values: material.applicationSurfaceTypes, col: 'col-12', itemCol: 'col-12 col-md-6' }), renderCheckboxGroup({ name: 'ecoCertifications', label: 'Zertifizierungen', options: state.lookups.ecoCertifications, values: material.ecoCertifications, col: 'col-12', itemCol: 'col-12 col-md-6' }), renderSwitchInput({ name: 'active', label: 'Aktiv', value: material.active, col: 'col-12 col-md-6' }), renderTextarea({ name: 'handlingNotes', label: 'Handhabungshinweise', value: material.handlingNotes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderMovementPanel() { var movement = state.forms.movement || createEmptyMovement(); return renderPanelScaffold((movement.id && recordExists('stockMovements', movement.id) ? 'Bestandsbewegung bearbeiten' : 'Bestandsbewegung erfassen'), 'movement', [ '', '
', '
Bestandsbewegung
', '
', renderSelectInput({ name: 'materialId', label: 'Material', optionsHtml: renderMaterialOptions(movement.materialId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'type', label: 'Typ', optionsHtml: renderOptionList(state.lookups.stockMovementTypes, movement.type), col: 'col-12 col-md-6' }), renderTextInput({ name: 'quantity', label: 'Menge', type: 'number', step: '0.01', value: movement.quantity, col: 'col-12 col-md-6' }), renderTextInput({ name: 'date', label: 'Datum', type: 'date', value: movement.date, col: 'col-12 col-md-6' }), renderTextarea({ name: 'notes', label: 'Notizen', value: movement.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderMachinePanel() { var machine = state.forms.machine || createEmptyMachine(); return renderPanelScaffold((machine.id && recordExists('machines', machine.id) ? 'Maschine bearbeiten' : 'Maschine anlegen'), 'machine', [ '', '
', '
Maschineneinsatz
', '
', renderTextInput({ name: 'name', label: 'Maschinenname', value: machine.name, required: true, col: 'col-12 col-md-6' }), renderSelectInput({ name: 'type', label: 'Maschinentyp', optionsHtml: renderOptionList(state.lookups.machineTypes, machine.type), col: 'col-12 col-md-6' }), renderTextInput({ name: 'serialNo', label: 'Seriennummer', value: machine.serialNo, col: 'col-12 col-md-6' }), renderSelectInput({ name: 'status', label: 'Status', optionsHtml: renderOptionList([{ value: 'verfuegbar', label: 'Verfügbar' }, { value: 'im_einsatz', label: 'Im Einsatz' }, { value: 'wartung', label: 'Wartung' }, { value: 'defekt', label: 'Defekt' }], machine.status), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'providerId', label: 'Zuordnung Dienstleister', optionsHtml: renderProviderOptions(machine.providerId, 'Intern / unzugeordnet'), col: 'col-12 col-md-6' }), renderTextInput({ name: 'lastServiceDate', label: 'Letzte Wartung', type: 'date', value: machine.lastServiceDate, col: 'col-12 col-md-3' }), renderTextInput({ name: 'nextServiceDate', label: 'Nächste Wartung', type: 'date', value: machine.nextServiceDate, col: 'col-12 col-md-3' }), renderTextarea({ name: 'notes', label: 'Notizen', value: machine.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function renderInvoicePanel() { var invoice = state.forms.invoice || createEmptyInvoice(); return renderPanelScaffold((invoice.id && recordExists('invoices', invoice.id) ? 'Rechnung bearbeiten' : 'Rechnung anlegen'), 'invoice', [ '', '
', '
Rechnung
', '
', renderSelectInput({ name: 'providerId', label: 'Dienstleister', optionsHtml: renderProviderOptions(invoice.providerId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderSelectInput({ name: 'assignmentId', label: 'Auftrag', optionsHtml: renderAssignmentOptions(invoice.assignmentId, 'Bitte wählen'), col: 'col-12 col-md-6' }), renderTextInput({ name: 'invoiceNo', label: 'Rechnungsnummer', value: invoice.invoiceNo, required: true, col: 'col-12 col-md-6' }), renderTextInput({ name: 'date', label: 'Rechnungsdatum', type: 'date', value: invoice.date, col: 'col-12 col-md-6' }), renderTextInput({ name: 'billedArea', label: 'Fakturierte Fläche (m²)', type: 'number', step: '0.01', value: invoice.billedArea, col: 'col-12 col-md-4' }), renderTextInput({ name: 'amountNet', label: 'Netto', type: 'number', step: '0.01', value: invoice.amountNet, col: 'col-12 col-md-4' }), renderTextInput({ name: 'amountGross', label: 'Brutto', type: 'number', step: '0.01', value: invoice.amountGross, col: 'col-12 col-md-4' }), renderSelectInput({ name: 'status', label: 'Status', optionsHtml: renderOptionList([{ value: 'offen', label: 'Offen' }, { value: 'geprueft', label: 'Geprüft' }, { value: 'gesperrt', label: 'Gesperrt' }, { value: 'abgelehnt', label: 'Abgelehnt' }], invoice.status), col: 'col-12 col-md-6' }), renderTextarea({ name: 'notes', label: 'Notizen', value: invoice.notes, rows: 4, col: 'col-12' }), '
', '
' ].join('')); } function openPanel(name, mode, id) { state.activePanel = { name: name, mode: mode || 'create', id: id || null }; if (name === 'zone') { state.forms.zone = id ? deepClone(getZoneById(id) || createEmptyZone()) : createEmptyZone(); } else if (name === 'service') { state.forms.service = id ? deepClone(state.data.serviceCatalog.find(function(item) { return item.id === id; }) || createEmptyService()) : createEmptyService(); } else if (name === 'checklist') { state.forms.checklist = id ? deepClone(getChecklistById(id) || createEmptyChecklist()) : createEmptyChecklist(); } else if (name === 'inspection') { state.forms.inspection = id ? deepClone(getInspectionById(id) || createEmptyInspection()) : createEmptyInspection(); } else if (name === 'defect') { state.forms.defect = id ? deepClone(state.data.defects.find(function(item) { return item.id === id; }) || createEmptyDefect()) : createEmptyDefect(); } else if (name === 'complaint') { state.forms.complaint = id ? deepClone(state.data.complaints.find(function(item) { return item.id === id; }) || createEmptyComplaint()) : createEmptyComplaint(); } else if (name === 'provider') { state.forms.provider = id ? deepClone(getProviderById(id) || createEmptyProvider()) : createEmptyProvider(); } else if (name === 'assignment') { state.forms.assignment = id ? deepClone(getAssignmentById(id) || createEmptyAssignment()) : createEmptyAssignment(); } else if (name === 'timesheet') { state.forms.timesheet = id ? deepClone(state.data.timesheets.find(function(item) { return item.id === id; }) || createEmptyTimesheet()) : createEmptyTimesheet(); } else if (name === 'material') { state.forms.material = id ? deepClone(getMaterialById(id) || createEmptyMaterial()) : createEmptyMaterial(); } else if (name === 'movement') { state.forms.movement = id ? deepClone(state.data.stockMovements.find(function(item) { return item.id === id; }) || createEmptyMovement()) : createEmptyMovement(); } else if (name === 'machine') { state.forms.machine = id ? deepClone(state.data.machines.find(function(item) { return item.id === id; }) || createEmptyMachine()) : createEmptyMachine(); } else if (name === 'invoice') { state.forms.invoice = id ? deepClone(state.data.invoices.find(function(item) { return item.id === id; }) || createEmptyInvoice()) : createEmptyInvoice(); } render(); } function closePanel() { state.activePanel = null; render(); } function selectZone(id) { state.selections.zoneId = id; render(); } function setActiveTab(tab) { if (safeArray(state.lookups.tabs).some(function(item) { return item.id === tab; })) { state.activeTab = tab; render(); } } function updateFilter(key, value) { if (!Object.prototype.hasOwnProperty.call(state.filters, key)) { return; } state.filters[key] = value; Object.keys(state.pagination).forEach(function(name) { state.pagination[name].page = 1; }); if (state.activeTab === 'berichte') { state.ui.activeReportPreview = buildReportPreview(state.ui.activeReportType || 'fortschritt'); } render(); } function paginateList(key, direction) { var config = state.pagination[key]; if (!config) { return; } if (direction === 'next') { config.page += 1; } else if (direction === 'prev') { config.page = Math.max(1, config.page - 1); } render(); } function bindEvents() { if (!state.root) { return; } storeListener(state.root, 'click', handleRootClick); storeListener(state.root, 'submit', handleRootSubmit); storeListener(state.root, 'change', handleRootChange); storeListener(state.root, 'input', debounce(handleRootInput, 60)); } function handleRootClick(event) { var actionElement = event.target.closest('[data-action]'); if (!actionElement) { return; } var action = actionElement.getAttribute('data-action'); var id = actionElement.getAttribute('data-id'); var panel = actionElement.getAttribute('data-panel'); var mode = actionElement.getAttribute('data-mode'); var tab = actionElement.getAttribute('data-tab'); var type = actionElement.getAttribute('data-type'); var key = actionElement.getAttribute('data-key'); var direction = actionElement.getAttribute('data-direction'); if (action === 'dismiss-toast') { dismissToast(id); return; } if (action === 'close-panel') { closePanel(); return; } if (action === 'switch-tab') { setActiveTab(tab); return; } if (action === 'refresh-module') { refresh(); return; } if (action === 'reset-filters') { resetFilters(); render(); return; } if (action === 'context-create') { openContextCreate(); return; } if (action === 'open-panel') { openPanel(panel, mode, id); return; } if (action === 'paginate') { paginateList(key, direction); return; } if (action === 'select-zone') { selectZone(id); return; } if (action === 'duplicate-zone') { duplicateZone(id); return; } if (action === 'delete-zone') { deleteZone(id); return; } if (action === 'delete-service') { deleteRecordAction('serviceCatalog', 'service-catalog', id, 'Leistung'); return; } if (action === 'delete-checklist') { deleteRecordAction('checklistTemplates', 'checklists', id, 'Checkliste'); return; } if (action === 'delete-inspection') { deleteRecordAction('inspections', 'inspections', id, 'Abnahme'); return; } if (action === 'delete-defect') { deleteRecordAction('defects', 'defects', id, 'Mangel'); return; } if (action === 'delete-complaint') { deleteRecordAction('complaints', 'complaints', id, 'Reklamation'); return; } if (action === 'delete-provider') { deleteProvider(id); return; } if (action === 'delete-assignment') { deleteRecordAction('assignments', 'assignments', id, 'Beauftragung'); return; } if (action === 'delete-timesheet') { deleteRecordAction('timesheets', 'timesheets', id, 'Stundenzettel'); return; } if (action === 'delete-material') { deleteRecordAction('materials', 'materials', id, 'Material'); return; } if (action === 'delete-movement') { deleteRecordAction('stockMovements', 'stock-movements', id, 'Bestandsbewegung'); return; } if (action === 'delete-machine') { deleteRecordAction('machines', 'machines', id, 'Maschine'); return; } if (action === 'delete-invoice') { deleteRecordAction('invoices', 'invoices', id, 'Rechnung'); return; } if (action === 'delete-report') { deleteRecordAction('reports', 'reports', id, 'Bericht'); return; } if (action === 'advance-inspection') { advanceInspectionWorkflow(id); return; } if (action === 'resolve-defect') { resolveDefect(id); return; } if (action === 'escalate-complaint') { escalateComplaint(id); return; } if (action === 'toggle-timesheet-approval') { toggleTimesheetApproval(id); return; } if (action === 'toggle-invoice-status') { toggleInvoiceStatus(id); return; } if (action === 'prefill-movement') { prefillMovement(id); return; } if (action === 'filter-provider') { state.filters.providerId = id; render(); return; } if (action === 'select-report-type') { state.ui.activeReportType = type; state.ui.activeReportPreview = buildReportPreview(type); render(); return; } if (action === 'generate-report') { generateReport(type); return; } if (action === 'preview-saved-report') { previewSavedReport(id); return; } if (action === 'export-report-pdf') { exportReport(type, 'pdf'); return; } if (action === 'export-report-excel') { exportReport(type, 'excel'); return; } if (action === 'export-saved-report-pdf') { exportSavedReport(id, 'pdf'); return; } if (action === 'export-saved-report-excel') { exportSavedReport(id, 'excel'); return; } if (action === 'export-zones-csv') { exportZonesCsv(); } } function handleRootSubmit(event) { var form = event.target.closest('form[data-form]'); if (!form) { return; } event.preventDefault(); var formType = form.getAttribute('data-form'); var raw = readFormData(form); if (formType === 'zone') { saveZone(raw); return; } if (formType === 'service') { saveService(raw); return; } if (formType === 'checklist') { saveChecklist(raw); return; } if (formType === 'inspection') { saveInspection(raw); return; } if (formType === 'defect') { saveDefect(raw); return; } if (formType === 'complaint') { saveComplaint(raw); return; } if (formType === 'provider') { saveProvider(raw); return; } if (formType === 'assignment') { saveAssignment(raw); return; } if (formType === 'timesheet') { saveTimesheet(raw); return; } if (formType === 'material') { saveMaterial(raw); return; } if (formType === 'movement') { saveMovement(raw); return; } if (formType === 'machine') { saveMachine(raw); return; } if (formType === 'invoice') { saveInvoice(raw); return; } } function handleRootChange(event) { var filterKey = event.target.getAttribute('data-filter'); if (filterKey) { updateFilter(filterKey, event.target.value); return; } if (state.activePanel && state.activePanel.name === 'zone') { syncZoneFormState(); } } function handleRootInput(event) { var filterKey = event.target.getAttribute('data-filter'); if (filterKey) { updateFilter(filterKey, event.target.value); return; } if (state.activePanel && state.activePanel.name === 'zone') { syncZoneFormState(); } } function syncZoneFormState() { if (!state.root) { return; } var form = state.root.querySelector('form[data-form="zone"]'); if (!form) { return; } var raw = readFormData(form); var tempZone = normalizeZone(raw); state.forms.zone = tempZone; var preview = state.root.querySelector('[data-role="zone-area-preview"]'); if (preview) { preview.outerHTML = renderZoneAreaPreview(tempZone); } } function openContextCreate() { if (state.activeTab === 'dashboard' || state.activeTab === 'zonen') { openPanel('zone', 'create'); return; } if (state.activeTab === 'leistungen') { openPanel('service', 'create'); return; } if (state.activeTab === 'qualitaet') { openPanel('inspection', 'create'); return; } if (state.activeTab === 'dienstleister') { openPanel('provider', 'create'); return; } if (state.activeTab === 'materialien') { openPanel('material', 'create'); return; } if (state.activeTab === 'berichte') { generateReport(state.ui.activeReportType || 'fortschritt'); } } function prefillMovement(materialId) { state.forms.movement = createEmptyMovement(); state.forms.movement.materialId = materialId; openPanel('movement', 'create'); } function previewSavedReport(id) { var report = state.data.reports.find(function(item) { return item.id === id; }); if (report) { state.ui.activeReportType = report.type; state.ui.activeReportPreview = report; render(); } } function duplicateZone(id) { var zone = getZoneById(id); if (!zone) { showToast('Zone nicht gefunden.', 'warning'); return; } var copy = deepClone(zone); copy.id = ''; copy.code = zone.code + '-COPY'; copy.title = zone.title + ' (Kopie)'; copy.createdAt = ''; copy.updatedAt = ''; state.forms.zone = copy; openPanel('zone', 'create'); } function deleteZone(id) { var dependentAssignments = state.data.assignments.filter(function(item) { return item.zoneId === id; }).length; var dependentInspections = state.data.inspections.filter(function(item) { return item.zoneId === id; }).length; if (dependentAssignments || dependentInspections) { var ok = window.confirm('Zone hat abhängige Beauftragungen oder Abnahmen. Trotzdem löschen?'); if (!ok) { return; } } deleteRecordAction('zones', 'zones', id, 'Zone'); } function deleteProvider(id) { var activeDependencies = state.data.assignments.filter(function(item) { return item.providerId === id; }).length + state.data.inspections.filter(function(item) { return item.providerId === id; }).length; if (activeDependencies) { var confirmDelete = window.confirm('Dienstleister ist in Vorgängen referenziert. Wirklich löschen?'); if (!confirmDelete) { return; } } deleteRecordAction('providers', 'providers', id, 'Dienstleister'); } function deleteRecordAction(collectionName, endpoint, id, label) { if (!id) { return; } var approved = window.confirm(label + ' wirklich löschen?'); if (!approved) { return; } deletePersistedRecord(endpoint, id); removeRecord(collectionName, id); recalcDashboardSnapshot(); render(); showToast(label + ' gelöscht.', 'success'); } function saveZone(raw) { var zone = normalizeZone(raw); var exists = recordExists('zones', zone.id); persistRecord('zones', 'zones', exists ? 'PUT' : 'POST', zone).then(function(saved) { var normalized = normalizeZone(saved); upsertRecord('zones', normalized); state.selections.zoneId = normalized.id; recalcDashboardSnapshot(); closePanel(); showToast('Zone gespeichert.', 'success'); }); } function saveService(raw) { var service = normalizeService(raw); var exists = recordExists('serviceCatalog', service.id); persistRecord('serviceCatalog', 'service-catalog', exists ? 'PUT' : 'POST', service).then(function(saved) { upsertRecord('serviceCatalog', normalizeService(saved)); recalcDashboardSnapshot(); closePanel(); showToast('Leistung gespeichert.', 'success'); }); } function saveChecklist(raw) { var checklist = normalizeChecklist(raw); var exists = recordExists('checklistTemplates', checklist.id); persistRecord('checklistTemplates', 'checklists', exists ? 'PUT' : 'POST', checklist).then(function(saved) { upsertRecord('checklistTemplates', normalizeChecklist(saved)); closePanel(); showToast('Checkliste gespeichert.', 'success'); }); } function saveInspection(raw) { if (raw.inspectedAt && raw.inspectedAt.length === 16) { raw.inspectedAt = raw.inspectedAt + ':00'; } var inspection = normalizeInspection(raw); var exists = recordExists('inspections', inspection.id); persistRecord('inspections', 'inspections', exists ? 'PUT' : 'POST', inspection).then(function(saved) { var normalized = normalizeInspection(saved); upsertRecord('inspections', normalized); syncZoneAcceptanceFromInspection(normalized); recalcDashboardSnapshot(); closePanel(); showToast('Abnahme gespeichert.', 'success'); }); } function saveDefect(raw) { var defect = normalizeDefect(raw); var exists = recordExists('defects', defect.id); persistRecord('defects', 'defects', exists ? 'PUT' : 'POST', defect).then(function(saved) { upsertRecord('defects', normalizeDefect(saved)); recalcDashboardSnapshot(); closePanel(); showToast('Mangel gespeichert.', 'success'); }); } function saveComplaint(raw) { var complaint = normalizeComplaint(raw); var exists = recordExists('complaints', complaint.id); persistRecord('complaints', 'complaints', exists ? 'PUT' : 'POST', complaint).then(function(saved) { upsertRecord('complaints', normalizeComplaint(saved)); recalcDashboardSnapshot(); closePanel(); showToast('Reklamation gespeichert.', 'success'); }); } function saveProvider(raw) { var provider = normalizeProvider(raw); var exists = recordExists('providers', provider.id); persistRecord('providers', 'providers', exists ? 'PUT' : 'POST', provider).then(function(saved) { upsertRecord('providers', normalizeProvider(saved)); recalcDashboardSnapshot(); closePanel(); showToast('Dienstleister gespeichert.', 'success'); }); } function saveAssignment(raw) { var assignment = normalizeAssignment(raw); var exists = recordExists('assignments', assignment.id); if (!assignment.orderNo) { assignment.orderNo = 'BGR-' + String(Date.now()).slice(-6); } persistRecord('assignments', 'assignments', exists ? 'PUT' : 'POST', assignment).then(function(saved) { var normalized = normalizeAssignment(saved); upsertRecord('assignments', normalized); var zone = getZoneById(normalized.zoneId); if (zone && (!zone.providerId || zone.providerId !== normalized.providerId)) { zone.providerId = normalized.providerId; zone.phase = normalized.phase || zone.phase; upsertRecord('zones', normalizeZone(zone)); } recalcDashboardSnapshot(); closePanel(); showToast('Beauftragung gespeichert.', 'success'); }); } function saveTimesheet(raw) { var timesheet = normalizeTimesheet(raw); var exists = recordExists('timesheets', timesheet.id); persistRecord('timesheets', 'timesheets', exists ? 'PUT' : 'POST', timesheet).then(function(saved) { upsertRecord('timesheets', normalizeTimesheet(saved)); closePanel(); showToast('Stundenzettel gespeichert.', 'success'); }); } function saveMaterial(raw) { var material = normalizeMaterial(raw); var exists = recordExists('materials', material.id); persistRecord('materials', 'materials', exists ? 'PUT' : 'POST', material).then(function(saved) { upsertRecord('materials', normalizeMaterial(saved)); closePanel(); showToast('Material gespeichert.', 'success'); }); } function saveMovement(raw) { var movement = normalizeMovement(raw); var exists = recordExists('stockMovements', movement.id); persistRecord('stockMovements', 'stock-movements', exists ? 'PUT' : 'POST', movement).then(function(saved) { var normalized = normalizeMovement(saved); upsertRecord('stockMovements', normalized); applyMovementToStock(normalized); closePanel(); showToast('Bestandsbewegung gespeichert.', 'success'); }); } function saveMachine(raw) { var machine = normalizeMachine(raw); var exists = recordExists('machines', machine.id); persistRecord('machines', 'machines', exists ? 'PUT' : 'POST', machine).then(function(saved) { upsertRecord('machines', normalizeMachine(saved)); closePanel(); showToast('Maschine gespeichert.', 'success'); }); } function saveInvoice(raw) { var invoice = normalizeInvoice(raw); var exists = recordExists('invoices', invoice.id); persistRecord('invoices', 'invoices', exists ? 'PUT' : 'POST', invoice).then(function(saved) { upsertRecord('invoices', normalizeInvoice(saved)); closePanel(); showToast('Rechnung gespeichert.', 'success'); }); } function applyMovementToStock(movement) { var material = getMaterialById(movement.materialId); if (!material) { return; } var existingMovements = state.data.stockMovements.filter(function(item) { return item.materialId === movement.materialId; }); var balance = 0; existingMovements.forEach(function(item) { if (item.type === 'zugang') { balance += toNumber(item.quantity, 0); } else if (item.type === 'verbrauch') { balance -= toNumber(item.quantity, 0); } else if (item.type === 'korrektur') { balance += toNumber(item.quantity, 0); } }); material.stock = round(Math.max(0, balance), 2); upsertRecord('materials', normalizeMaterial(material)); } function syncZoneAcceptanceFromInspection(inspection) { var zone = getZoneById(inspection.zoneId); if (!zone) { return; } if (inspection.result === 'bestanden' && inspection.workflowStage === 'freigabe') { zone.acceptanceStatus = 'freigegeben'; zone.status = 'freigegeben'; zone.cleanedArea = zone.totalArea; } else if (inspection.result === 'bestanden') { zone.acceptanceStatus = 'bestanden'; zone.status = 'qs'; zone.cleanedArea = zone.totalArea; } else if (inspection.result === 'nacharbeit') { zone.acceptanceStatus = 'nacharbeit'; zone.status = 'nacharbeit'; } else { zone.status = inspection.workflowStage === 'reinigung' ? 'in_arbeit' : 'qs'; } upsertRecord('zones', normalizeZone(zone)); } function advanceInspectionWorkflow(id) { var inspection = getInspectionById(id); if (!inspection) { return; } var stages = ['reinigung', 'qs', 'bauleiter', 'freigabe']; var currentIndex = stages.indexOf(inspection.workflowStage); if (currentIndex === -1) { inspection.workflowStage = 'qs'; } else { inspection.workflowStage = stages[Math.min(stages.length - 1, currentIndex + 1)]; } if (inspection.workflowStage === 'freigabe' && inspection.result === 'bestanden') { syncZoneAcceptanceFromInspection(inspection); } upsertRecord('inspections', normalizeInspection(inspection)); recalcDashboardSnapshot(); render(); showToast('Workflow-Stufe aktualisiert.', 'success'); } function resolveDefect(id) { var defect = state.data.defects.find(function(item) { return item.id === id; }); if (!defect) { return; } defect.status = 'erledigt'; defect.resolvedAt = nowIso(); upsertRecord('defects', normalizeDefect(defect)); render(); showToast('Mangel als erledigt markiert.', 'success'); } function escalateComplaint(id) { var complaint = state.data.complaints.find(function(item) { return item.id === id; }); if (!complaint) { return; } complaint.escalationLevel = Math.min(5, toNumber(complaint.escalationLevel, 1) + 1); complaint.status = 'in_bearbeitung'; upsertRecord('complaints', normalizeComplaint(complaint)); render(); showToast('Reklamation eskaliert.', 'warning'); } function toggleTimesheetApproval(id) { var timesheet = state.data.timesheets.find(function(item) { return item.id === id; }); if (!timesheet) { return; } timesheet.approved = !timesheet.approved; upsertRecord('timesheets', normalizeTimesheet(timesheet)); render(); showToast('Freigabestatus geändert.', 'success'); } function toggleInvoiceStatus(id) { var invoice = state.data.invoices.find(function(item) { return item.id === id; }); if (!invoice) { return; } var statuses = ['offen', 'geprueft', 'gesperrt']; var currentIndex = statuses.indexOf(invoice.status); invoice.status = statuses[(currentIndex + 1) % statuses.length]; upsertRecord('invoices', normalizeInvoice(invoice)); render(); showToast('Rechnungsstatus aktualisiert.', 'success'); } function generateReport(type) { var reportType = type || state.ui.activeReportType || 'fortschritt'; var preview = buildReportPreview(reportType); preview.summary = 'Generiert auf Basis des aktuellen Filters am ' + formatDateTime(preview.generatedAt) + '.'; state.ui.activeReportPreview = preview; state.ui.activeReportType = reportType; apiCall('reports/generate', 'POST', { type: reportType, filters: state.filters }).then(function(payload) { var result = payload && payload.data ? payload.data : payload; if (result && result.rows) { preview = merge(preview, result); } }).catch(noop).finally(function() { preview.id = preview.id || uid('report'); state.data.reports.unshift(preview); state.data.reports = state.data.reports.slice(0, 50); render(); showToast('Bericht erzeugt.', 'success'); }); } function exportReport(type, format) { var reportType = type || state.ui.activeReportType || 'fortschritt'; var report = state.ui.activeReportPreview || buildReportPreview(reportType); exportReportPayload(report, format || 'pdf'); } function exportSavedReport(id, format) { var report = state.data.reports.find(function(item) { return item.id === id; }); if (!report) { showToast('Bericht nicht gefunden.', 'warning'); return; } exportReportPayload(report, format || 'pdf'); } function exportReportPayload(report, format) { var payload = { type: report.type, title: report.title, generatedAt: report.generatedAt, filters: state.filters, rows: report.rows }; if (format === 'excel') { apiCall('reports/export/excel', 'POST', payload).then(function(response) { if (typeof response === 'string') { downloadTextFile(slugifyFilename(report.title || 'report') + '.csv', response, 'text/csv;charset=utf-8;'); return; } }).catch(function() { var rows = safeArray(report.rows); if (!rows.length) { showToast('Kein Inhalt für den Export vorhanden.', 'warning'); return; } var headers = Object.keys(rows[0]); var csvRows = [headers]; rows.forEach(function(row) { csvRows.push(headers.map(function(header) { return row[header]; })); }); downloadTextFile(slugifyFilename(report.title || 'report') + '.csv', createCsv(csvRows), 'text/csv;charset=utf-8;'); }); return; } apiCall('reports/export/pdf', 'POST', payload).then(function(response) { if (typeof response === 'string') { openPrintWindow(report.title || 'Bericht', response); } }).catch(function() { openPrintWindow(report.title || 'Bericht', buildPrintableReportHtml(report)); }); } function exportZonesCsv() { var zones = selectFilteredZones(); if (!zones.length) { showToast('Keine Zonen für den Export vorhanden.', 'warning'); return; } var rows = [ ['Code', 'Gebäude', 'Gebäudeteil', 'Geschoss', 'Raum', 'Phase', 'Status', 'Abnahme', 'Fläche gesamt m²', 'Gereinigt m²', 'Fortschritt %', 'Dienstleister', 'Termin'] ]; zones.forEach(function(zone) { var provider = getProviderById(zone.providerId); rows.push([ zone.code, zone.building, zone.buildingPart, zone.floor, zone.room, getLookupLabel(state.lookups.phases, zone.phase, zone.phase), getLookupLabel(state.lookups.zoneStatuses, zone.status, zone.status), getLookupLabel(state.lookups.acceptanceStatuses, zone.acceptanceStatus, zone.acceptanceStatus), zone.totalArea, zone.cleanedArea, zone.progressPct, provider ? provider.name : '', zone.dueDate ]); }); downloadTextFile('reinigungszonen.csv', createCsv(rows), 'text/csv;charset=utf-8;'); } function buildPrintableReportHtml(report) { var rows = safeArray(report.rows); var headers = rows.length ? Object.keys(rows[0]) : []; return [ '

' + escapeHtml(report.title || 'Bericht') + '

', '

Generiert: ' + escapeHtml(formatDateTime(report.generatedAt || nowIso())) + '

', report.summary ? '

' + escapeHtml(report.summary) + '

' : '', rows.length ? '' + headers.map(function(header) { return ''; }).join('') + '' + rows.map(function(row) { return '' + headers.map(function(header) { return ''; }).join('') + ''; }).join('') + '
' + escapeHtml(header) + '
' + escapeHtml(row[header] == null ? '' : String(row[header])) + '
' : '

Keine Daten vorhanden.

' ].join(''); } function slugifyFilename(text) { return String(text || 'export') .toLowerCase() .replace(/[^a-z0-9äöüß]+/gi, '-') .replace(/^-+|-+$/g, '') .replace(/-+/g, '-'); } function init(container, options) { if (state.initialized) { destroy(); } var target = resolveContainer(container) || resolveContainer((options || {}).container) || document.body; state.options = merge(DEFAULT_OPTIONS, options || {}); state.debug = !!state.options.debug; state.container = target; state.destroyed = false; state.initialized = false; state.activeTab = 'dashboard'; state.activePanel = null; resetFilters(); resetAllForms(); injectStyles(); state.root = document.createElement('section'); state.root.id = ROOT_ID; state.root.className = 'container-fluid px-0'; if (state.container !== document.body) { state.container.innerHTML = ''; state.container.appendChild(state.root); } else { document.body.appendChild(state.root); } renderRootSkeleton(); bindEvents(); scheduleAutoRefresh(); loadBootstrapData().then(function() { return refreshDashboardData(); }).catch(noop).finally(function() { state.initialized = true; recalcDashboardSnapshot(); render(); }); return window.BauGenioReinigung; } function destroy() { clearRuntime(); state.destroyed = true; state.initialized = false; state.activePanel = null; resetAllForms(); if (state.root && state.root.parentNode) { state.root.parentNode.removeChild(state.root); } state.root = null; state.container = null; var style = document.getElementById(STYLE_ID); if (style && style.parentNode) { style.parentNode.removeChild(style); } } function refresh() { if (!state.root) { return Promise.resolve(); } return loadBootstrapData().then(function() { return refreshDashboardData(); }).finally(function() { recalcDashboardSnapshot(); render(); showToast('Modul aktualisiert.', 'success', 2200); }); } window.BauGenioReinigung = { init: init, destroy: destroy, refresh: refresh }; })(); function duplicateZone(id) { var zone = getZoneById(id); if (!zone) { showToast('Zone nicht gefunden.', 'warning'); return; } var copy = deepClone(zone); copy.id = ''; copy.code = zone.code + '-COPY'; copy.title = zone.title + ' (Kopie)'; copy.createdAt = ''; copy.updatedAt = ''; state.forms.zone = copy; openPanel('zone', 'create'); } function duplicateZone(id) { var zone = getZoneById(id); if (!zone) { showToast('Zone nicht gefunden.', 'warning'); return; } var copy = deepClone(zone); copy.id = ''; copy.code = zone.code + '-COPY'; copy.title = zone.title + ' (Kopie)'; copy.createdAt = ''; copy.updatedAt = ''; state.activePanel = { name: 'zone', mode: 'create', id: null }; state.forms.zone = copy; render(); } function prefillMovement(materialId) { state.forms.movement = createEmptyMovement(); state.forms.movement.materialId = materialId; openPanel('movement', 'create'); } function prefillMovement(materialId) { state.activePanel = { name: 'movement', mode: 'create', id: null }; state.forms.movement = createEmptyMovement(); state.forms.movement.materialId = materialId; render(); } ', '' ].join(''); } function exportAreasCsv() { const rows = [['Code', 'Name', 'Bauabschnitt', 'Flächentyp', 'Nutzungsart', 'Status', 'Fläche_m2', 'Material', 'Muster', 'Bettung', 'Fuge', 'Bestandsmaterial', 'Kosten_Plan', 'Kosten_Ist']]; getFilteredAreas().forEach(function(area) { rows.push([ area.code, area.name, getSectionName(area.sectionId), mapAreaTypeLabel(area.areaType), mapUsageTypeLabel(area.usageType), mapStatusLabel(area.status)[0], toNumber(getValueByPath(area, 'geometry.areaM2'), 2), getMaterialName(getValueByPath(area, 'materialPlan.materialId')), mapPatternLabel(getValueByPath(area, 'materialPlan.layingPattern')), mapBeddingLabel(getValueByPath(area, 'materialPlan.beddingMaterial')), mapJointLabel(getValueByPath(area, 'materialPlan.jointMaterial')), getValueByPath(area, 'materialPlan.sourceType'), toNumber(sumBy([getValueByPath(area, 'costs.plannedMaterialCost'), getValueByPath(area, 'costs.plannedLaborCost'), getValueByPath(area, 'costs.plannedEquipmentCost')]), 2), toNumber(sumBy([getValueByPath(area, 'costs.actualMaterialCost'), getValueByPath(area, 'costs.actualLaborCost'), getValueByPath(area, 'costs.actualEquipmentCost')]), 2) ]); }); downloadBlob(textBlob(toCsv(rows), 'text/csv;charset=utf-8'), 'pflasterbau-flaechen.csv'); showToast('CSV exportiert', 'Flächenexport wurde heruntergeladen.', 'success'); } function exportMaterialsCsv() { const rows = [['Name', 'Art', 'Naturstein', 'Qualität', 'Format', 'Farbe', 'Charge', 'Lieferant', 'Bestand', 'Einheit', 'Quelle', 'Zertifikat', 'Dokumentenstatus']]; getFilteredMaterials().forEach(function(material) { rows.push([ material.name, mapMaterialTypeLabel(material.type), mapStoneLabel(material.naturalStoneType), firstNonEmpty(material.quality, material.salvageQuality, ''), material.format, material.color, material.charge, getSupplierName(material.supplierId), toNumber(material.stockQuantity, 2), material.stockUnit, material.sourceType, getValueByPath(material, 'certificates.certificateRef'), mapDocumentStateLabel(getValueByPath(material, 'certificates.documentState')) ]); }); downloadBlob(textBlob(toCsv(rows), 'text/csv;charset=utf-8'), 'pflasterbau-materialien.csv'); showToast('CSV exportiert', 'Materialexport wurde heruntergeladen.', 'success'); } function exportQualityCsv() { const rows = [['Datum', 'Fläche', 'Prüfer', 'Score', 'Status', 'Ebenheit_max_mm', 'Fugen_min_mm', 'Fugen_max_mm', 'Quergefälle_Abw', 'Längsgefälle_Abw', 'Druckfestigkeit', 'SRT', 'Empfehlung']]; getFilteredQualityChecks().forEach(function(qc) { rows.push([ qc.date, getAreaName(qc.areaId), qc.inspector, qc.score, mapStatusLabel(qc.status)[0], getValueByPath(qc, 'result.evenness.maxMeasuredMm'), getValueByPath(qc, 'result.joints.minMeasuredMm'), getValueByPath(qc, 'result.joints.maxMeasuredMm'), getValueByPath(qc, 'result.crossSlope.deviationPercent'), getValueByPath(qc, 'result.longitudinalSlope.deviationPercent'), qc.compressiveStrengthNmm2, qc.srtValue, qc.recommendation ]); }); downloadBlob(textBlob(toCsv(rows), 'text/csv;charset=utf-8'), 'pflasterbau-qualitaet.csv'); showToast('CSV exportiert', 'Qualitätsbericht wurde heruntergeladen.', 'success'); } function exportExcelWorkbook() { const sheets = [ { name: 'Flaechen', rows: buildAreaSheetRows() }, { name: 'Materialien', rows: buildMaterialSheetRows() }, { name: 'Pruefungen', rows: buildQualitySheetRows() }, { name: 'Verlegung', rows: buildLayingSheetRows() } ]; const workbookXml = buildExcelXmlWorkbook(sheets); downloadBlob(textBlob(workbookXml, 'application/vnd.ms-excel;charset=utf-8'), 'pflasterbau-export.xls'); showToast('Excel exportiert', 'XLS-kompatible Arbeitsmappe wurde heruntergeladen.', 'success'); } function buildAreaSheetRows() { const rows = [['Code', 'Name', 'Bauabschnitt', 'Typ', 'Nutzungsart', 'Status', 'Flaeche_m2', 'Fortschritt_%', 'Material', 'Muster', 'Bettung', 'Fuge', 'Bestandsmaterial']]; getFilteredAreas().forEach(function(area) { rows.push([ area.code, area.name, getSectionName(area.sectionId), mapAreaTypeLabel(area.areaType), mapUsageTypeLabel(area.usageType), mapStatusLabel(area.status)[0], toNumber(getValueByPath(area, 'geometry.areaM2'), 2), toNumber(getValueByPath(area, 'derived.progressPercent'), 1), getMaterialName(getValueByPath(area, 'materialPlan.materialId')), mapPatternLabel(getValueByPath(area, 'materialPlan.layingPattern')), mapBeddingLabel(getValueByPath(area, 'materialPlan.beddingMaterial')), mapJointLabel(getValueByPath(area, 'materialPlan.jointMaterial')), getValueByPath(area, 'materialPlan.sourceType') ]); }); return rows; } function buildMaterialSheetRows() { const rows = [['Name', 'Art', 'Naturstein', 'Qualitaet', 'Format', 'Farbe', 'Charge', 'Lieferant', 'Bestand', 'Einheit', 'Quelle']]; getFilteredMaterials().forEach(function(material) { rows.push([ material.name, mapMaterialTypeLabel(material.type), mapStoneLabel(material.naturalStoneType), firstNonEmpty(material.quality, material.salvageQuality, ''), material.format, material.color, material.charge, getSupplierName(material.supplierId), toNumber(material.stockQuantity, 2), material.stockUnit, material.sourceType ]); }); return rows; } function buildQualitySheetRows() { const rows = [['Datum', 'Fläche', 'Prüfer', 'Score', 'Status', 'Ebenheit_max_mm', 'Fugen_min_mm', 'Fugen_max_mm', 'Quergefälle_Abw_%', 'Längsgefälle_Abw_%', 'SRT']]; getFilteredQualityChecks().forEach(function(qc) { rows.push([ qc.date, getAreaName(qc.areaId), qc.inspector, qc.score, mapStatusLabel(qc.status)[0], getValueByPath(qc, 'result.evenness.maxMeasuredMm'), getValueByPath(qc, 'result.joints.minMeasuredMm'), getValueByPath(qc, 'result.joints.maxMeasuredMm'), getValueByPath(qc, 'result.crossSlope.deviationPercent'), getValueByPath(qc, 'result.longitudinalSlope.deviationPercent'), qc.srtValue ]); }); return rows; } function buildLayingSheetRows() { const rows = [['Datum', 'Fläche', 'Team', 'Witterung', 'Leistung_m2', 'Stein_m2', 'Bettung_t', 'Fuge_t', 'Schnitt_%', 'Verschnitt_%']]; getFilteredLayingLogs().forEach(function(log) { rows.push([ log.date, getAreaName(log.areaId), getTeamName(log.teamId), mapWeatherLabel(log.weather), log.areaCompletedM2, log.stoneConsumedM2, log.beddingConsumedTons, log.jointConsumedTons, log.cutSharePercent, log.wastePercent ]); }); return rows; } function xmlEscape(value) { return String(value === undefined || value === null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function buildExcelXmlWorkbook(sheets) { return [ '', '', '', '', '', '', safeArray(sheets).map(function(sheet) { return [ '', '', safeArray(sheet.rows).map(function(row, rowIndex) { return [ '', safeArray(row).map(function(cell) { const style = rowIndex === 0 ? ' ss:StyleID="Header"' : ''; const type = typeof cell === 'number' ? 'Number' : 'String'; return '' + xmlEscape(cell) + ''; }).join(''), '' ].join(''); }).join(''), '
', '
' ].join(''); }).join(''), '
' ].join(''); } async function refresh() { calculateDerivedData(); renderLoadingState(); await loadAllData(); } async function init(options) { if (!hasWindow()) { throw new Error('BauGenioPflasterbau benötigt eine Browser-Umgebung.'); } if (state.initialized) { logDebug('Module already initialized, refreshing instead.'); state.options = deepMerge({}, state.options, options || {}); state.root = resolveRoot(state.options.root) || state.root; await refresh(); return window.BauGenioPflasterbau; } resetState(); state.options = deepMerge({}, DEFAULT_OPTIONS, options || {}); state.root = resolveRoot(state.options.root); if (!state.root) { throw new Error('Kein gültiges Root-Element für BauGenioPflasterbau gefunden.'); } injectStyles(); restoreUiState(); renderShell(); bindEvents(); calculateDerivedData(); render(); state.initialized = true; state.destroyed = false; if (state.options.autoLoad) { await loadAllData(); } else if (state.options.useDemoDataOnError) { applyDemoData(); calculateDerivedData(); render(); } return window.BauGenioPflasterbau; } function destroy() { abortAllRequests(); offAll(); closeModal(); destroyChartCache(); if (state.root) { destroyChildren(state.root); } if (state.refs.toastHost) { destroyChildren(state.refs.toastHost); } removeStyles(); state.destroyed = true; state.initialized = false; resetState(); } window.BauGenioPflasterbau = { init: init, destroy: destroy, refresh: refresh }; })(); '); printWindow.document.close(); printWindow.focus(); printWindow.print(); } function saveLocalCache() { try { const payload = { trassen: state.trassen, kabel: state.kabel, belegungen: state.belegungen, pruefungen: state.pruefungen, verlegungen: state.verlegungen, milestones: state.milestones, struktur: state.struktur, rules: state.rules, savedAt: nowISO() }; window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (error) { noop(error); } } function loadLocalCache() { try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) { return null; } return JSON.parse(raw); } catch (error) { return null; } } function createOptionList(items, valueKey, labelKey, selectedValue, includeEmpty, emptyLabel) { const output = []; if (includeEmpty) { output.push(''); } ensureArray(items).forEach(function(item) { const value = item && typeof item === 'object' ? item[valueKey] : item; const label = item && typeof item === 'object' ? item[labelKey] : item; const selected = String(selectedValue || '') === String(value || '') ? ' selected' : ''; output.push(''); }); return output.join(''); } function createSimpleOptionList(items, selectedValue, includeEmpty, emptyLabel) { const output = []; if (includeEmpty) { output.push(''); } ensureArray(items).forEach(function(item) { const selected = String(selectedValue || '') === String(item || '') ? ' selected' : ''; output.push(''); }); return output.join(''); } function readInputValue(form, name) { if (!form) { return ''; } const field = form.elements[name]; if (!field) { return ''; } if (field.type === 'checkbox') { return field.checked; } return field.value; } function readTextAreaLines(form, name) { const raw = readInputValue(form, name); return String(raw || '') .split(/\r?\n/) .map(function(line) { return line.trim(); }) .filter(Boolean); } function parseBoolean(value) { if (typeof value === 'boolean') { return value; } const normalized = normalizeString(value); return normalized === 'true' || normalized === '1' || normalized === 'ja' || normalized === 'yes' || normalized === 'on'; } function parseNumberArray(value) { return String(value || '') .split(',') .map(function(part) { return toNumber(part.trim(), NaN); }) .filter(function(item) { return Number.isFinite(item); }); } function clearCharts() { Object.keys(state.charts || {}).forEach(function(key) { const chart = state.charts[key]; if (chart && typeof chart.destroy === 'function') { chart.destroy(); } }); state.charts = {}; } function clearCache() { state.cache.dashboard = null; state.cache.collisions = null; state.cache.lookup = null; } function queueCacheInvalidation() { clearCache(); saveLocalCache(); } function normalizeNodeKey(value) { return slugify(value || 'node'); } function buildApiUrl(endpoint) { const normalized = String(endpoint || '').replace(/^\/+/, ''); return API_BASE + '/' + normalized; } async function apiCall(endpoint, method, data) { const url = buildApiUrl(endpoint); const options = { method: method || 'GET', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }; if (options.method !== 'GET' && options.method !== 'HEAD' && data !== undefined) { options.body = JSON.stringify(data); } const response = await fetch(url, options); const contentType = response.headers.get('content-type') || ''; const isJson = contentType.indexOf('application/json') !== -1; const payload = isJson ? await response.json() : await response.text(); if (!response.ok) { const error = new Error('API-Fehler ' + response.status + ' bei ' + endpoint); error.status = response.status; error.payload = payload; throw error; } return payload; } function getTrasseTypMeta(value) { return TRASSEN_TYPEN.find(function(item) { return item.value === value; }) || { value: value, label: value || '—', defaultFillRate: 0.50, icon: '▤' }; } function getKategorieMeta(value) { return KABEL_KATEGORIEN.find(function(item) { return item.value === value; }) || { value: value, label: value || '—' }; } function getPruefartLabel(value) { const match = PRUEFARTEN.find(function(item) { return item.value === value; }); return match ? match.label : (value || '—'); } function getKabelTypLabel(kategorie, typ) { const group = KABEL_TYPEN[kategorie] || []; const match = group.find(function(item) { return item.value === typ; }); return match ? match.label : (typ || '—'); } function getAllKabelTypeOptions() { const options = []; Object.keys(KABEL_TYPEN).forEach(function(groupKey) { ensureArray(KABEL_TYPEN[groupKey]).forEach(function(item) { options.push({ value: item.value, label: item.label, kategorie: groupKey }); }); }); return options; } function buildDemoData() { const struktur = { gebaeude: ['Haus A', 'Haus B'], geschosse: ['UG', 'EG', '1. OG', '2. OG'], verteiler: ['HV-A', 'UV-A-EG', 'UV-A-1OG', 'UV-B-EG', 'NSHV-B'], patchfelder: ['PF-A-EG-01', 'PF-A-1OG-01', 'PF-B-EG-01'], raeume: [ 'Technik UG', 'Serverraum EG', 'Büro 1.01', 'Büro 1.02', 'Flur EG', 'Konferenz 1.15', 'Werkstatt B-EG', 'Leitwarte' ] }; const trassen = [ { id: 'trasse-001', bezeichnung: 'TR-A-UG-HV-01', typ: 'kabelrinne', gebaeude: 'Haus A', geschoss: 'UG', nutzbreiteMm: 300, nutzhoeheMm: 60, tragfaehigkeitKgM: 40, material: 'stahl_verzinkt', brandschutzklasse: 'e30', trennstegVorhanden: true, status: 'freigegeben', notizen: 'Haupttrasse Technikbereich', abschnitte: [ { id: 'section-001', bezeichnung: 'UG-HV-01A', startpunkt: 'Technik UG', endpunkt: 'Schacht A-UG', laengeM: 18, corridor: 'Nordtrasse', startX: 0, startY: 0, endX: 18, endY: 0, verknuepftMit: ['section-002'] }, { id: 'section-002', bezeichnung: 'UG-HV-01B', startpunkt: 'Schacht A-UG', endpunkt: 'HV-A', laengeM: 9, corridor: 'Nordtrasse', startX: 18, startY: 0, endX: 27, endY: 2, verknuepftMit: ['section-001'] } ] }, { id: 'trasse-002', bezeichnung: 'TR-A-SCHACHT-STEIG-01', typ: 'kabelschacht', gebaeude: 'Haus A', geschoss: 'UG', nutzbreiteMm: 400, nutzhoeheMm: 120, tragfaehigkeitKgM: 100, material: 'beton', brandschutzklasse: 'e90', trennstegVorhanden: true, status: 'freigegeben', notizen: 'Vertikaler Schacht Haus A', abschnitte: [ { id: 'section-003', bezeichnung: 'Steigzone UG-EG', startpunkt: 'Schacht A-UG', endpunkt: 'Schacht A-EG', laengeM: 4.5, corridor: 'Steigschacht A', startX: 18, startY: 0, endX: 18, endY: 4.5, verknuepftMit: ['section-004'] }, { id: 'section-004', bezeichnung: 'Steigzone EG-1OG', startpunkt: 'Schacht A-EG', endpunkt: 'Schacht A-1OG', laengeM: 4.2, corridor: 'Steigschacht A', startX: 18, startY: 4.5, endX: 18, endY: 8.7, verknuepftMit: ['section-003', 'section-005'] }, { id: 'section-005', bezeichnung: 'Steigzone 1OG-2OG', startpunkt: 'Schacht A-1OG', endpunkt: 'Schacht A-2OG', laengeM: 4.1, corridor: 'Steigschacht A', startX: 18, startY: 8.7, endX: 18, endY: 12.8, verknuepftMit: ['section-004'] } ] }, { id: 'trasse-003', bezeichnung: 'TR-A-EG-BODEN-01', typ: 'bodenkanal', gebaeude: 'Haus A', geschoss: 'EG', nutzbreiteMm: 220, nutzhoeheMm: 55, tragfaehigkeitKgM: 25, material: 'stahl_verzinkt', brandschutzklasse: 'halogenfrei', trennstegVorhanden: false, status: 'geplant', notizen: 'EG Bodenkanal Westflügel', abschnitte: [ { id: 'section-006', bezeichnung: 'EG-West-01', startpunkt: 'Schacht A-EG', endpunkt: 'Serverraum EG', laengeM: 14, corridor: 'EG-Westflur', startX: 18, startY: 4.5, endX: 32, endY: 4.5, verknuepftMit: ['section-007'] }, { id: 'section-007', bezeichnung: 'EG-West-02', startpunkt: 'Serverraum EG', endpunkt: 'UV-A-EG', laengeM: 6, corridor: 'Serverraum-Anbindung', startX: 32, startY: 4.5, endX: 38, endY: 5.5, verknuepftMit: ['section-006'] } ] }, { id: 'trasse-004', bezeichnung: 'TR-A-1OG-PRITSCHE-01', typ: 'kabelpritsche', gebaeude: 'Haus A', geschoss: '1. OG', nutzbreiteMm: 450, nutzhoeheMm: 80, tragfaehigkeitKgM: 60, material: 'aluminium', brandschutzklasse: 'halogenfrei', trennstegVorhanden: true, status: 'freigegeben', notizen: 'Flurtrasse Bürogeschoss', abschnitte: [ { id: 'section-008', bezeichnung: '1OG-Flur-Ost', startpunkt: 'Schacht A-1OG', endpunkt: 'Büro 1.01', laengeM: 9, corridor: 'Flur 1OG Ost', startX: 18, startY: 8.7, endX: 27, endY: 8.7, verknuepftMit: ['section-009'] }, { id: 'section-009', bezeichnung: '1OG-Flur-West', startpunkt: 'Büro 1.01', endpunkt: 'Büro 1.02', laengeM: 8, corridor: 'Flur 1OG Ost', startX: 27, startY: 8.7, endX: 35, endY: 8.7, verknuepftMit: ['section-008', 'section-010'] }, { id: 'section-010', bezeichnung: '1OG-Konferenz', startpunkt: 'Büro 1.02', endpunkt: 'Konferenz 1.15', laengeM: 11, corridor: 'Flur 1OG West', startX: 35, startY: 8.7, endX: 46, endY: 9.5, verknuepftMit: ['section-009'] } ] }, { id: 'trasse-005', bezeichnung: 'TR-B-EG-KANAL-01', typ: 'kabelkanal', gebaeude: 'Haus B', geschoss: 'EG', nutzbreiteMm: 200, nutzhoeheMm: 60, tragfaehigkeitKgM: 30, material: 'kunststoff', brandschutzklasse: 'ohne', trennstegVorhanden: false, status: 'in_verlegung', notizen: 'Werkstattkanal', abschnitte: [ { id: 'section-011', bezeichnung: 'Werkstatt Ost', startpunkt: 'NSHV-B', endpunkt: 'Werkstatt B-EG', laengeM: 12, corridor: 'Werkstatt Trasse', startX: 0, startY: 20, endX: 12, endY: 20, verknuepftMit: ['section-012'] }, { id: 'section-012', bezeichnung: 'Werkstatt Leitwarte', startpunkt: 'Werkstatt B-EG', endpunkt: 'Leitwarte', laengeM: 10, corridor: 'Werkstatt Trasse', startX: 12, startY: 20, endX: 22, endY: 20, verknuepftMit: ['section-011'] } ] }, { id: 'trasse-006', bezeichnung: 'TR-A-EG-ROHR-RESERVE', typ: 'leerrohr', gebaeude: 'Haus A', geschoss: 'EG', nutzbreiteMm: 50, nutzhoeheMm: 50, tragfaehigkeitKgM: 12, material: 'kunststoff', brandschutzklasse: 'halogenfrei', trennstegVorhanden: false, status: 'geplant', notizen: 'Reserveverrohrung Eingangsbereich', abschnitte: [ { id: 'section-013', bezeichnung: 'Eingang Süd', startpunkt: 'Flur EG', endpunkt: 'Serverraum EG', laengeM: 16, corridor: 'EG-Südflur', startX: 40, startY: 7, endX: 32, endY: 4.5, verknuepftMit: [] } ] }, { id: 'trasse-007', bezeichnung: 'TR-A-1OG-ROHR-LWL', typ: 'installationsrohr', gebaeude: 'Haus A', geschoss: '1. OG', nutzbreiteMm: 63, nutzhoeheMm: 63, tragfaehigkeitKgM: 15, material: 'kunststoff', brandschutzklasse: 'halogenfrei', trennstegVorhanden: false, status: 'freigegeben', notizen: 'LWL Schutzrohr zwischen Schacht und Konferenz', abschnitte: [ { id: 'section-014', bezeichnung: 'LWL-Konferenz', startpunkt: 'Schacht A-1OG', endpunkt: 'Konferenz 1.15', laengeM: 19, corridor: 'LWL Route 1OG', startX: 18, startY: 8.7, endX: 46, endY: 9.5, verknuepftMit: [] } ] }, { id: 'trasse-008', bezeichnung: 'TR-A-EG-KOLLISION', typ: 'kabelkanal', gebaeude: 'Haus A', geschoss: 'EG', nutzbreiteMm: 120, nutzhoeheMm: 50, tragfaehigkeitKgM: 20, material: 'kunststoff', brandschutzklasse: 'ohne', trennstegVorhanden: false, status: 'geplant', notizen: 'Testtrasse für Kollisionshinweis', abschnitte: [ { id: 'section-015', bezeichnung: 'EG-Kollision-01', startpunkt: 'Schacht A-EG', endpunkt: 'Serverraum EG', laengeM: 14, corridor: 'EG-Westflur', startX: 18, startY: 4.5, endX: 32, endY: 4.5, verknuepftMit: [] } ] } ]; const kabel = [ { id: 'kabel-001', nummer: 'K-A-001', kategorie: 'starkstrom', kabeltyp: 'nym', querschnittMm2: 5 * 2.5, adern: 5, laengeGeplantM: 0, laengeGemessenM: 30.5, hersteller: 'Prysmian', farbe: 'grau', startpunkt: 'HV-A', endpunkt: 'UV-A-EG', gebaeude: 'Haus A', geschoss: 'UG', reserve: false, ersatz: false, status: 'verlegt', pruefstatus: 'geprueft', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: addDaysISO(todayISO(), -4), route: null, notizen: 'Versorgung UV EG' }, { id: 'kabel-002', nummer: 'K-A-002', kategorie: 'daten', kabeltyp: 'cat7', querschnittMm2: 4, adern: 8, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Draka', farbe: 'orange', startpunkt: 'PF-A-EG-01', endpunkt: 'Serverraum EG', gebaeude: 'Haus A', geschoss: 'EG', reserve: false, ersatz: false, status: 'geplant', pruefstatus: 'geplant', zugabeStartM: 2, zugabeEndM: 2, zugtermin: addDaysISO(todayISO(), 2), route: null, notizen: 'Datenuplink West' }, { id: 'kabel-003', nummer: 'K-A-003', kategorie: 'glasfaser', kabeltyp: 'lwl_sm', querschnittMm2: 12, adern: 12, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Corning', farbe: 'gelb', startpunkt: 'Serverraum EG', endpunkt: 'Konferenz 1.15', gebaeude: 'Haus A', geschoss: 'EG', reserve: false, ersatz: true, status: 'geplant', pruefstatus: 'ungeplant', zugabeStartM: 3, zugabeEndM: 3, zugtermin: addDaysISO(todayISO(), 6), route: null, notizen: 'Backbone Konferenz inkl. Reserve' }, { id: 'kabel-004', nummer: 'K-A-004', kategorie: 'brandmelde', kabeltyp: 'e30_bma', querschnittMm2: 2 * 1.5, adern: 2, laengeGeplantM: 0, laengeGemessenM: 27, hersteller: 'Dätwyler', farbe: 'rot', startpunkt: 'HV-A', endpunkt: 'Flur EG', gebaeude: 'Haus A', geschoss: 'UG', reserve: false, ersatz: false, status: 'verlegt', pruefstatus: 'maengel', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: addDaysISO(todayISO(), -12), route: null, notizen: 'Brandmelde-Anbindung Eingang' }, { id: 'kabel-005', nummer: 'K-A-005', kategorie: 'bus', kabeltyp: 'knx', querschnittMm2: 2 * 2 * 0.8, adern: 4, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Helukabel', farbe: 'grün', startpunkt: 'UV-A-EG', endpunkt: 'Konferenz 1.15', gebaeude: 'Haus A', geschoss: 'EG', reserve: false, ersatz: false, status: 'in_verlegung', pruefstatus: 'geplant', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: addDaysISO(todayISO(), 1), route: null, notizen: 'Raumautomation' }, { id: 'kabel-006', nummer: 'K-B-001', kategorie: 'starkstrom', kabeltyp: 'nyy', querschnittMm2: 4 * 16, adern: 4, laengeGeplantM: 0, laengeGemessenM: 25, hersteller: 'Nexans', farbe: 'schwarz', startpunkt: 'NSHV-B', endpunkt: 'Leitwarte', gebaeude: 'Haus B', geschoss: 'EG', reserve: false, ersatz: false, status: 'in_verlegung', pruefstatus: 'geplant', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: addDaysISO(todayISO(), 3), route: null, notizen: 'Leitwartenversorgung' }, { id: 'kabel-007', nummer: 'K-A-006', kategorie: 'daten', kabeltyp: 'cat6a', querschnittMm2: 4, adern: 8, laengeGeplantM: 0, laengeGemessenM: 18, hersteller: 'Leoni', farbe: 'blau', startpunkt: 'PF-A-1OG-01', endpunkt: 'Büro 1.01', gebaeude: 'Haus A', geschoss: '1. OG', reserve: false, ersatz: false, status: 'geprueft', pruefstatus: 'freigegeben', zugabeStartM: 2, zugabeEndM: 2, zugtermin: addDaysISO(todayISO(), -20), route: null, notizen: 'AP Büro 1.01' }, { id: 'kabel-008', nummer: 'K-A-007', kategorie: 'steuerung', kabeltyp: 'steuerleitung', querschnittMm2: 12 * 1.5, adern: 12, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Lapp', farbe: 'grau', startpunkt: 'UV-A-EG', endpunkt: 'Leitwarte', gebaeude: 'Haus A', geschoss: 'EG', reserve: true, ersatz: false, status: 'geplant', pruefstatus: 'ungeplant', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: addDaysISO(todayISO(), 10), route: null, notizen: 'Reserve für Gebäudeleittechnik' }, { id: 'kabel-009', nummer: 'K-A-008', kategorie: 'sicherheit', kabeltyp: 'video', querschnittMm2: 6, adern: 6, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Belden', farbe: 'schwarz', startpunkt: 'Serverraum EG', endpunkt: 'Flur EG', gebaeude: 'Haus A', geschoss: 'EG', reserve: false, ersatz: false, status: 'geplant', pruefstatus: 'ungeplant', zugabeStartM: 2, zugabeEndM: 2, zugtermin: addDaysISO(todayISO(), 4), route: null, notizen: 'Kamera Süd' }, { id: 'kabel-010', nummer: 'K-A-009', kategorie: 'starkstrom', kabeltyp: 'nycwy', querschnittMm2: 5 * 10, adern: 5, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Prysmian', farbe: 'schwarz', startpunkt: 'HV-A', endpunkt: 'Konferenz 1.15', gebaeude: 'Haus A', geschoss: 'UG', reserve: false, ersatz: false, status: 'geplant', pruefstatus: 'geplant', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: addDaysISO(todayISO(), 5), route: null, notizen: 'Unterverteilung Konferenz' }, { id: 'kabel-011', nummer: 'K-A-010', kategorie: 'glasfaser', kabeltyp: 'lwl_mm', querschnittMm2: 24, adern: 24, laengeGeplantM: 0, laengeGemessenM: 0, hersteller: 'Corning', farbe: 'orange', startpunkt: 'Serverraum EG', endpunkt: 'Büro 1.02', gebaeude: 'Haus A', geschoss: 'EG', reserve: true, ersatz: true, status: 'geplant', pruefstatus: 'ungeplant', zugabeStartM: 3, zugabeEndM: 3, zugtermin: addDaysISO(todayISO(), 12), route: null, notizen: 'Reserve LWL Nord' }, { id: 'kabel-012', nummer: 'K-B-002', kategorie: 'daten', kabeltyp: 'cat7a', querschnittMm2: 4, adern: 8, laengeGeplantM: 0, laengeGemessenM: 14, hersteller: 'Draka', farbe: 'violett', startpunkt: 'Leitwarte', endpunkt: 'Werkstatt B-EG', gebaeude: 'Haus B', geschoss: 'EG', reserve: false, ersatz: false, status: 'verlegt', pruefstatus: 'geprueft', zugabeStartM: 2, zugabeEndM: 2, zugtermin: addDaysISO(todayISO(), -8), route: null, notizen: 'Werkstatt Netzwerk' } ]; const belegungen = [ { id: 'bel-001', kabelId: 'kabel-001', trasseId: 'trasse-001', sectionId: 'section-001', meter: 18, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-002', kabelId: 'kabel-001', trasseId: 'trasse-001', sectionId: 'section-002', meter: 9, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-003', kabelId: 'kabel-001', trasseId: 'trasse-002', sectionId: 'section-003', meter: 4.5, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-004', kabelId: 'kabel-002', trasseId: 'trasse-003', sectionId: 'section-006', meter: 14, createdAt: nowISO(), manuell: true, warnings: ['Trennabstand manuell prüfen'] }, { id: 'bel-005', kabelId: 'kabel-004', trasseId: 'trasse-001', sectionId: 'section-001', meter: 18, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-006', kabelId: 'kabel-006', trasseId: 'trasse-005', sectionId: 'section-011', meter: 12, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-007', kabelId: 'kabel-006', trasseId: 'trasse-005', sectionId: 'section-012', meter: 10, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-008', kabelId: 'kabel-007', trasseId: 'trasse-004', sectionId: 'section-008', meter: 9, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-009', kabelId: 'kabel-007', trasseId: 'trasse-004', sectionId: 'section-009', meter: 8, createdAt: nowISO(), manuell: false, warnings: [] }, { id: 'bel-010', kabelId: 'kabel-012', trasseId: 'trasse-005', sectionId: 'section-012', meter: 10, createdAt: nowISO(), manuell: true, warnings: ['Gemischte Belegung in Werkstattkanal'] } ]; const verlegungen = [ { id: 'ver-001', kabelId: 'kabel-001', datum: addDaysISO(todayISO(), -5), team: 'Team Alpha', wetter: 'innenbereich', protokolltyp: 'Kabelzug', fotos: ['foto-hv-01.jpg', 'foto-hv-02.jpg'], zugkraftN: 540, temperaturC: 20, zugrichtung: 'von Technik UG nach HV-A', biegeradiusMm: 120, isolationMOhm: 250, durchgangOk: true, mantelpruefungOk: true, sichtpruefungOk: true, anschlussBestaetigungA: true, anschlussBestaetigungB: true, notizen: 'Sauber verlegt, keine Beschädigungen.' }, { id: 'ver-002', kabelId: 'kabel-004', datum: addDaysISO(todayISO(), -12), team: 'Team Beta', wetter: 'innenbereich', protokolltyp: 'Verlegeprotokoll', fotos: ['bma-flur-01.jpg'], zugkraftN: 310, temperaturC: 19, zugrichtung: 'von HV-A nach Flur EG', biegeradiusMm: 95, isolationMOhm: 180, durchgangOk: true, mantelpruefungOk: true, sichtpruefungOk: false, anschlussBestaetigungA: true, anschlussBestaetigungB: false, notizen: 'Sichtprüfung wegen Quetschstelle erneut ansetzen.' }, { id: 'ver-003', kabelId: 'kabel-006', datum: addDaysISO(todayISO(), -1), team: 'Subunternehmer 1', wetter: 'trocken', protokolltyp: 'Zugplanung', fotos: ['leitwarte-01.jpg'], zugkraftN: 870, temperaturC: 14, zugrichtung: 'von NSHV-B Richtung Leitwarte', biegeradiusMm: 160, isolationMOhm: 0, durchgangOk: false, mantelpruefungOk: true, sichtpruefungOk: true, anschlussBestaetigungA: false, anschlussBestaetigungB: false, notizen: 'Einzug noch nicht abgeschlossen.' } ]; const pruefungen = [ { id: 'prf-001', kabelId: 'kabel-001', planDatum: addDaysISO(todayISO(), -3), pruefDatum: addDaysISO(todayISO(), -2), pruefart: 'vde_0100_600', status: 'freigegeben', messwerte: { isolationMOhm: 250, schleifenimpedanzOhm: 0.41, rcdMs: 0, daempfungDb: 0, otdr: '', fluke: '', catZertifizierung: '' }, maengel: [], nachprueftermin: '', freigegebenVon: 'Prüfteam Nord', notizen: 'Messwerte im Soll.' }, { id: 'prf-002', kabelId: 'kabel-004', planDatum: addDaysISO(todayISO(), -10), pruefDatum: addDaysISO(todayISO(), -9), pruefart: 'sichtpruefung', status: 'maengel', messwerte: { isolationMOhm: 180, schleifenimpedanzOhm: 0, rcdMs: 0, daempfungDb: 0, otdr: '', fluke: '', catZertifizierung: '' }, maengel: [ { id: 'ma-001', beschreibung: 'Quetschstelle im Flur EG', frist: addDaysISO(todayISO(), 3), behoben: false } ], nachprueftermin: addDaysISO(todayISO(), 5), freigegebenVon: '', notizen: 'Nacharbeit Mantelschutz.' }, { id: 'prf-003', kabelId: 'kabel-007', planDatum: addDaysISO(todayISO(), -18), pruefDatum: addDaysISO(todayISO(), -17), pruefart: 'fluke', status: 'freigegeben', messwerte: { isolationMOhm: 0, schleifenimpedanzOhm: 0, rcdMs: 0, daempfungDb: 0, otdr: '', fluke: 'PASS', catZertifizierung: 'Cat6a Channel PASS' }, maengel: [], nachprueftermin: '', freigegebenVon: 'Prüfteam Süd', notizen: 'Zertifikat vorhanden.' }, { id: 'prf-004', kabelId: 'kabel-003', planDatum: addDaysISO(todayISO(), 8), pruefDatum: '', pruefart: 'otdr', status: 'geplant', messwerte: { isolationMOhm: 0, schleifenimpedanzOhm: 0, rcdMs: 0, daempfungDb: 0.2, otdr: '', fluke: '', catZertifizierung: '' }, maengel: [], nachprueftermin: '', freigegebenVon: '', notizen: 'Prüfung nach Einzug und Spleiß.' }, { id: 'prf-005', kabelId: 'kabel-006', planDatum: addDaysISO(todayISO(), 4), pruefDatum: '', pruefart: 'isolation', status: 'geplant', messwerte: { isolationMOhm: 0, schleifenimpedanzOhm: 0, rcdMs: 0, daempfungDb: 0, otdr: '', fluke: '', catZertifizierung: '' }, maengel: [], nachprueftermin: '', freigegebenVon: '', notizen: 'Nach Abschluss Zugtermin.' } ]; const milestones = [ { id: 'ms-001', titel: 'Kabelzug K-A-002', typ: 'zugtermin', datum: addDaysISO(todayISO(), 2), bezugId: 'kabel-002', status: 'geplant' }, { id: 'ms-002', titel: 'Nachprüfung K-A-004', typ: 'nachpruefung', datum: addDaysISO(todayISO(), 5), bezugId: 'prf-002', status: 'maengel' }, { id: 'ms-003', titel: 'OTDR K-A-003', typ: 'pruefung', datum: addDaysISO(todayISO(), 8), bezugId: 'prf-004', status: 'geplant' }, { id: 'ms-004', titel: 'Kabelzug K-A-004 überfällig', typ: 'zugtermin', datum: addDaysISO(todayISO(), -12), bezugId: 'kabel-004', status: 'maengel' } ]; return { struktur: struktur, trassen: trassen, kabel: kabel, belegungen: belegungen, pruefungen: pruefungen, verlegungen: verlegungen, milestones: milestones, rules: deepClone(DEFAULT_RULES) }; } function mergeDataset(payload) { const source = ensureObject(payload); state.trassen = ensureArray(source.trassen); state.kabel = ensureArray(source.kabel); state.belegungen = ensureArray(source.belegungen); state.pruefungen = ensureArray(source.pruefungen); state.verlegungen = ensureArray(source.verlegungen); state.milestones = ensureArray(source.milestones); state.struktur = Object.assign({ gebaeude: [], geschosse: [], verteiler: [], patchfelder: [], raeume: [] }, ensureObject(source.struktur)); state.rules = Object.assign(deepClone(DEFAULT_RULES), ensureObject(source.rules)); } async function loadInitialData() { state.loading = true; renderApp(); let loaded = false; try { const [trassen, kabel, belegungen, pruefungen, verlegungen, milestones, struktur] = await Promise.all([ apiCall('trassen', 'GET'), apiCall('kabel', 'GET'), apiCall('belegungen', 'GET'), apiCall('pruefungen', 'GET'), apiCall('verlegungen', 'GET'), apiCall('milestones', 'GET'), apiCall('struktur', 'GET') ]); mergeDataset({ trassen: trassen && trassen.data ? trassen.data : trassen, kabel: kabel && kabel.data ? kabel.data : kabel, belegungen: belegungen && belegungen.data ? belegungen.data : belegungen, pruefungen: pruefungen && pruefungen.data ? pruefungen.data : pruefungen, verlegungen: verlegungen && verlegungen.data ? verlegungen.data : verlegungen, milestones: milestones && milestones.data ? milestones.data : milestones, struktur: struktur && struktur.data ? struktur.data : struktur, rules: DEFAULT_RULES }); loaded = true; state.demoMode = false; } catch (error) { const cached = loadLocalCache(); if (cached) { mergeDataset(cached); loaded = true; state.demoMode = true; notify('warning', 'API nicht erreichbar. Lokalen Cache geladen.'); } } if (!loaded) { const demo = buildDemoData(); mergeDataset(demo); state.demoMode = true; notify('info', 'Demo-Daten geladen, weil keine API-Daten verfügbar waren.'); } refreshDerivedData(); state.loading = false; state.ui.lastRefreshAt = nowISO(); renderApp(); } function getAllSections() { const sections = []; state.trassen.forEach(function(trasse) { ensureArray(trasse.abschnitte).forEach(function(section) { sections.push(Object.assign({}, section, { trasseId: trasse.id, trasseBezeichnung: trasse.bezeichnung, trasseTyp: trasse.typ, gebaeude: trasse.gebaeude, geschoss: trasse.geschoss })); }); }); return sections; } function getKabelById(id) { return findById(state.kabel, id); } function getTrasseById(id) { return findById(state.trassen, id); } function getPruefungById(id) { return findById(state.pruefungen, id); } function getVerlegungById(id) { return findById(state.verlegungen, id); } function getSectionById(sectionId) { const sections = getAllSections(); return findById(sections, sectionId); } function getKabelDiameterEstimateMm(kabel) { const item = ensureObject(kabel); const category = item.kategorie; const type = item.kabeltyp; const adern = toNumber(item.adern, 0); const q = toNumber(item.querschnittMm2, 0); if (category === 'daten') { if (type === 'cat8') { return 9.5; } if (type === 'cat7a') { return 8.6; } if (type === 'cat7') { return 8.0; } return 7.5; } if (category === 'glasfaser') { if (type === 'breakout_fiber') { return 12; } if (toNumber(item.adern, 0) >= 24) { return 11.5; } return 8.5; } if (category === 'bus') { return 6.8; } if (category === 'brandmelde') { return 7.0; } if (category === 'steuerung') { return clamp(Math.sqrt(Math.max(q, 1)) * 1.7 + Math.max(adern * 0.2, 1), 7, 22); } if (category === 'sicherheit') { return clamp(Math.sqrt(Math.max(q, 1)) * 1.5 + Math.max(adern * 0.15, 1.2), 6, 18); } if (category === 'schwachstrom') { return clamp(Math.sqrt(Math.max(q, 1)) * 1.3 + Math.max(adern * 0.15, 1.2), 5, 14); } return clamp(Math.sqrt(Math.max(q, 1)) * 1.6 + Math.max(adern * 0.35, 1.5), 8, 34); } function getKabelAreaMm2(kabel) { const d = getKabelDiameterEstimateMm(kabel); return Math.PI * Math.pow(d / 2, 2); } function getTrasseCapacityAreaMm2(trasse) { const item = ensureObject(trasse); const width = toNumber(item.nutzbreiteMm, 0); const height = toNumber(item.nutzhoeheMm, 0); return width * height; } function getTrasseMaxFillRate(trasse) { const item = ensureObject(trasse); if (Number.isFinite(item.maxFillRate)) { return toNumber(item.maxFillRate, 0.5); } const typeMeta = getTrasseTypMeta(item.typ); return toNumber(state.rules.fillRateDefaults[item.typ], typeMeta.defaultFillRate || 0.5); } function getBelegungenForKabel(kabelId) { return state.belegungen.filter(function(item) { return String(item.kabelId) === String(kabelId); }); } function getBelegungenForTrasse(trasseId) { return state.belegungen.filter(function(item) { return String(item.trasseId) === String(trasseId); }); } function getCableLengthOnBelegungen(kabelId) { return sumBy(getBelegungenForKabel(kabelId), function(item) { return item.meter; }); } function getTrasseUsedAreaMm2(trasseId) { const belegungen = getBelegungenForTrasse(trasseId); return sumBy(belegungen, function(item) { const cable = getKabelById(item.kabelId); return getKabelAreaMm2(cable); }); } function getTrasseFillRate(trasseId) { const trasse = getTrasseById(trasseId); if (!trasse) { return 0; } const capacity = getTrasseCapacityAreaMm2(trasse); if (!capacity) { return 0; } return getTrasseUsedAreaMm2(trasseId) / capacity; } function getTrasseAssignedMeter(trasseId) { return sumBy(getBelegungenForTrasse(trasseId), function(item) { return item.meter; }); } function getKabelSignalClass(kabel) { const category = ensureObject(kabel).kategorie; if (category === 'starkstrom') { return 'starkstrom'; } if (category === 'daten') { return 'daten'; } if (category === 'glasfaser') { return 'glasfaser'; } if (category === 'steuerung') { return 'steuerung'; } if (category === 'brandmelde') { return 'brandmelde'; } if (category === 'bus') { return 'bus'; } if (category === 'sicherheit') { return 'sicherheit'; } return 'schwachstrom'; } function getRequiredSeparationMm(cableA, cableB, trasse) { const classA = getKabelSignalClass(cableA); const classB = getKabelSignalClass(cableB); const matrix = state.rules.separationRulesMm || {}; const direct = matrix[classA] && matrix[classA][classB]; const fallback = matrix[classB] && matrix[classB][classA]; const requirement = toNumber(direct, toNumber(fallback, 0)); const hasDivider = !!ensureObject(trasse).trennstegVorhanden; if (hasDivider && state.rules.defaultDividerMakesSeparationMm) { return 0; } return requirement; } function getCableDefaultAllowance(cable) { const item = ensureObject(cable); const category = item.kategorie || 'starkstrom'; return toNumber(state.rules.categoryAllowancePerEndM[category], 1.5); } function getCableMinBendingRadiusMm(cable) { const item = ensureObject(cable); const factor = toNumber(state.rules.bendingFactor[item.kategorie], 8); return round(getKabelDiameterEstimateMm(item) * factor, 0); } function getCableMaxPullForceN(cable) { const item = ensureObject(cable); const factor = toNumber(state.rules.pullingForceFactor[item.kategorie], 20); return round(getKabelDiameterEstimateMm(item) * factor, 0); } function estimateSectionLength(section) { const item = ensureObject(section); if (toNumber(item.laengeM, 0) > 0) { return toNumber(item.laengeM, 0); } const sx = toNumber(item.startX, NaN); const sy = toNumber(item.startY, NaN); const ex = toNumber(item.endX, NaN); const ey = toNumber(item.endY, NaN); if (Number.isFinite(sx) && Number.isFinite(sy) && Number.isFinite(ex) && Number.isFinite(ey)) { return round(Math.hypot(ex - sx, ey - sy), 1); } return 0; } function buildGraphFromTrassen() { const graph = { nodes: {}, edges: {} }; getAllSections().forEach(function(section) { const from = normalizeNodeKey(section.startpunkt); const to = normalizeNodeKey(section.endpunkt); const length = estimateSectionLength(section); if (!graph.nodes[from]) { graph.nodes[from] = { id: from, label: section.startpunkt }; } if (!graph.nodes[to]) { graph.nodes[to] = { id: to, label: section.endpunkt }; } if (!graph.edges[from]) { graph.edges[from] = []; } if (!graph.edges[to]) { graph.edges[to] = []; } const edge = { from: from, to: to, length: length, sectionId: section.id, trasseId: section.trasseId, trasseBezeichnung: section.trasseBezeichnung, sectionBezeichnung: section.bezeichnung, corridor: section.corridor }; graph.edges[from].push(edge); graph.edges[to].push(Object.assign({}, edge, { from: to, to: from })); }); return graph; } function runDijkstra(graph, startKey, endKey) { if (!graph.nodes[startKey] || !graph.nodes[endKey]) { return null; } const distances = {}; const previous = {}; const previousEdge = {}; const visited = {}; const queue = []; Object.keys(graph.nodes).forEach(function(nodeKey) { distances[nodeKey] = nodeKey === startKey ? 0 : Infinity; queue.push(nodeKey); }); while (queue.length) { queue.sort(function(a, b) { return distances[a] - distances[b]; }); const current = queue.shift(); if (current === endKey) { break; } if (visited[current]) { continue; } visited[current] = true; const edges = graph.edges[current] || []; edges.forEach(function(edge) { const candidate = distances[current] + toNumber(edge.length, 0); if (candidate < distances[edge.to]) { distances[edge.to] = candidate; previous[edge.to] = current; previousEdge[edge.to] = edge; } }); } if (!Number.isFinite(distances[endKey])) { return null; } const nodes = []; const edges = []; let currentNode = endKey; while (currentNode) { nodes.unshift(currentNode); const edge = previousEdge[currentNode]; if (edge) { edges.unshift(edge); } currentNode = previous[currentNode]; } return { startKey: startKey, endKey: endKey, nodes: nodes, edges: edges, lengthM: round(distances[endKey], 1) }; } function findShortestRoute(startpoint, endpoint) { const graph = buildGraphFromTrassen(); return runDijkstra(graph, normalizeNodeKey(startpoint), normalizeNodeKey(endpoint)); } function removeBelegungenForCable(kabelId) { state.belegungen = state.belegungen.filter(function(item) { return String(item.kabelId) !== String(kabelId); }); } function detectSeparationWarningsForTrasse(trasseId, targetCableId) { const trasse = getTrasseById(trasseId); const targetCable = getKabelById(targetCableId); if (!trasse || !targetCable) { return []; } const assigned = getBelegungenForTrasse(trasseId) .map(function(item) { return getKabelById(item.kabelId); }) .filter(Boolean) .filter(function(item) { return String(item.id) !== String(targetCableId); }); const warnings = []; assigned.forEach(function(otherCable) { const required = getRequiredSeparationMm(targetCable, otherCable, trasse); if (required > 0) { warnings.push('Trennung zu ' + otherCable.nummer + ' erforderlich: ' + required + ' mm'); } }); return unique(warnings); } function evaluateRouteCompliance(route, cable) { const warnings = []; const blocking = []; const item = ensureObject(cable); ensureArray(route && route.edges).forEach(function(edge) { const trasse = getTrasseById(edge.trasseId); if (!trasse) { return; } const capacity = getTrasseCapacityAreaMm2(trasse); const currentArea = getTrasseUsedAreaMm2(trasse.id); const newArea = currentArea + getKabelAreaMm2(item); const maxArea = capacity * getTrasseMaxFillRate(trasse); const projectedRate = capacity ? newArea / capacity : 0; const separationWarnings = detectSeparationWarningsForTrasse(trasse.id, item.id); if (maxArea > 0 && newArea > maxArea) { blocking.push('Trasse ' + trasse.bezeichnung + ' überschreitet Füllgrad (' + formatPercent(projectedRate, 1) + ')'); } else if (projectedRate >= state.rules.overloadWarningThreshold) { warnings.push('Trasse ' + trasse.bezeichnung + ' nähert sich kritischem Füllgrad (' + formatPercent(projectedRate, 1) + ')'); } separationWarnings.forEach(function(text) { warnings.push('Trasse ' + trasse.bezeichnung + ': ' + text); }); }); return { warnings: unique(warnings), blocking: unique(blocking) }; } function createBelegungenForRoute(kabelId, route, isManual) { const cable = getKabelById(kabelId); if (!cable || !route || !route.edges) { return []; } const created = route.edges.map(function(edge) { return { id: createUid('bel'), kabelId: kabelId, trasseId: edge.trasseId, sectionId: edge.sectionId, meter: round(edge.length, 1), createdAt: nowISO(), manuell: !!isManual, warnings: detectSeparationWarningsForTrasse(edge.trasseId, kabelId) }; }); state.belegungen = state.belegungen.concat(created); return created; } function updateCableRouteData(cable, route, compliance) { if (!cable) { return; } const allowanceStart = toNumber(cable.zugabeStartM, getCableDefaultAllowance(cable)); const allowanceEnd = toNumber(cable.zugabeEndM, getCableDefaultAllowance(cable)); const routeLength = route ? toNumber(route.lengthM, 0) : 0; cable.route = route ? { startpunkt: cable.startpunkt, endpunkt: cable.endpunkt, nodeKeys: ensureArray(route.nodes), sectionIds: ensureArray(route.edges).map(function(edge) { return edge.sectionId; }), trasseIds: unique(ensureArray(route.edges).map(function(edge) { return edge.trasseId; })), trassen: unique(ensureArray(route.edges).map(function(edge) { return edge.trasseBezeichnung; })), lengthM: routeLength, warnings: compliance ? ensureArray(compliance.warnings) : [], blocking: compliance ? ensureArray(compliance.blocking) : [] } : null; cable.laengeGeplantM = round(routeLength + allowanceStart + allowanceEnd, 1); cable.minBiegeradiusMm = getCableMinBendingRadiusMm(cable); cable.maxZugkraftN = getCableMaxPullForceN(cable); } function autoPlanCableRoute(kabelId) { const cable = getKabelById(kabelId); if (!cable) { return { ok: false, message: 'Kabel nicht gefunden.' }; } const route = findShortestRoute(cable.startpunkt, cable.endpunkt); if (!route) { return { ok: false, message: 'Keine durchgängige Route zwischen Start- und Endpunkt gefunden.' }; } const compliance = evaluateRouteCompliance(route, cable); removeBelegungenForCable(kabelId); createBelegungenForRoute(kabelId, route, false); updateCableRouteData(cable, route, compliance); if (cable.status === 'ungeplant') { cable.status = 'geplant'; } queueCacheInvalidation(); return { ok: true, route: route, compliance: compliance, message: compliance.blocking.length ? 'Route geplant, aber mit Blockern.' : compliance.warnings.length ? 'Route geplant mit Hinweisen.' : 'Route erfolgreich geplant.' }; } function autoPlanAllUnroutedCables() { const results = []; state.kabel.forEach(function(item) { if (!item.route) { results.push(autoPlanCableRoute(item.id)); } }); refreshDerivedData(); renderApp(); return results; } function calculateCableStatus(cable) { const item = ensureObject(cable); if (item.pruefstatus === 'freigegeben') { return 'freigegeben'; } if (item.pruefstatus === 'maengel') { return 'maengel'; } if (item.pruefstatus === 'geprueft' && item.status !== 'verlegt') { return 'geprueft'; } if (item.status) { return item.status; } return 'ungeplant'; } function getCableRouteLength(cable) { const item = ensureObject(cable); if (item.route && Number.isFinite(toNumber(item.route.lengthM, NaN))) { return toNumber(item.route.lengthM, 0); } return getCableLengthOnBelegungen(item.id); } function deriveMilestones() { const items = []; state.kabel.forEach(function(cable) { if (cable.zugtermin) { items.push({ id: createUid('msc'), titel: 'Kabelzug ' + cable.nummer, typ: 'zugtermin', datum: cable.zugtermin, bezugId: cable.id, status: cable.status }); } }); state.pruefungen.forEach(function(test) { if (test.planDatum) { const cable = getKabelById(test.kabelId); items.push({ id: createUid('msp'), titel: 'Prüfung ' + (cable ? cable.nummer : test.kabelId), typ: 'pruefung', datum: test.planDatum, bezugId: test.id, status: test.status }); } if (test.nachprueftermin) { const cable = getKabelById(test.kabelId); items.push({ id: createUid('msn'), titel: 'Nachprüfung ' + (cable ? cable.nummer : test.kabelId), typ: 'nachpruefung', datum: test.nachprueftermin, bezugId: test.id, status: test.status }); } }); const seeded = ensureArray(state.milestones).map(function(item) { return Object.assign({}, item); }); return sortBy(uniqueBy(seeded.concat(items), function(item) { return item.typ + '|' + item.titel + '|' + item.datum; }), function(item) { return item.datum || '9999-12-31'; }, 'asc'); } function calculateOverdueMilestones() { return deriveMilestones().filter(function(item) { return item.datum && isPastDate(item.datum) && item.status !== 'freigegeben'; }); } function calculateUpcomingMilestones() { const limitDate = addDaysISO(todayISO(), toNumber(state.rules.milestonePreviewDays, 21)); return deriveMilestones().filter(function(item) { return item.datum && item.datum >= todayISO() && item.datum <= limitDate; }); } function calculateOpenTestsCount() { return state.pruefungen.filter(function(item) { return item.status !== 'freigegeben'; }).length; } function calculateThisWeekPullCount() { return state.kabel.filter(function(item) { return isSameWeek(item.zugtermin); }).length; } function calculateCableLengthTotals() { const planned = sumBy(state.kabel, function(item) { return toNumber(item.laengeGeplantM, 0); }); const installed = sumBy(state.kabel, function(item) { return toNumber(item.laengeGemessenM || (item.status === 'verlegt' || item.status === 'geprueft' || item.status === 'freigegeben' ? item.laengeGeplantM : 0), 0); }); const checked = sumBy(state.kabel.filter(function(item) { return item.pruefstatus === 'geprueft' || item.pruefstatus === 'freigegeben'; }), function(item) { return toNumber(item.laengeGemessenM || item.laengeGeplantM, 0); }); return { planned: round(planned, 1), installed: round(installed, 1), checked: round(checked, 1) }; } function calculateAverageFillRate() { if (!state.trassen.length) { return 0; } return averageBy(state.trassen, function(item) { return getTrasseFillRate(item.id); }); } function calculateBuildingProgress() { const groups = groupBy(state.kabel, function(item) { return item.gebaeude || 'Unbekannt'; }); return Object.keys(groups).map(function(key) { const items = groups[key]; const planned = sumBy(items, function(item) { return item.laengeGeplantM; }); const installed = sumBy(items, function(item) { return item.status === 'verlegt' || item.status === 'geprueft' || item.status === 'freigegeben' ? toNumber(item.laengeGemessenM || item.laengeGeplantM, 0) : 0; }); return { label: key, planned: round(planned, 1), installed: round(installed, 1), progress: planned ? installed / planned : 0 }; }); } function calculateFloorProgress() { const groups = groupBy(state.kabel, function(item) { return (item.gebaeude || 'Unbekannt') + ' / ' + (item.geschoss || '—'); }); return Object.keys(groups).map(function(key) { const items = groups[key]; const planned = sumBy(items, function(item) { return item.laengeGeplantM; }); const installed = sumBy(items, function(item) { return item.status === 'verlegt' || item.status === 'geprueft' || item.status === 'freigegeben' ? toNumber(item.laengeGemessenM || item.laengeGeplantM, 0) : 0; }); return { label: key, planned: round(planned, 1), installed: round(installed, 1), progress: planned ? installed / planned : 0 }; }); } function calculateTrasseProgress() { return state.trassen.map(function(trasse) { const assigned = getBelegungenForTrasse(trasse.id); const planned = sumBy(assigned, function(item) { return item.meter; }); const installed = sumBy(assigned.filter(function(item) { const cable = getKabelById(item.kabelId); return cable && (cable.status === 'verlegt' || cable.status === 'geprueft' || cable.status === 'freigegeben'); }), function(item) { return item.meter; }); return { label: trasse.bezeichnung, planned: round(planned, 1), installed: round(installed, 1), progress: planned ? installed / planned : 0, fillRate: getTrasseFillRate(trasse.id), maxFillRate: getTrasseMaxFillRate(trasse) }; }); } function lineSegmentsIntersect(a, b, c, d) { function ccw(p1, p2, p3) { return (p3.y - p1.y) * (p2.x - p1.x) > (p2.y - p1.y) * (p3.x - p1.x); } return ccw(a, c, d) !== ccw(b, c, d) && ccw(a, b, c) !== ccw(a, b, d); } function detectTrasseCollisions() { const sections = getAllSections(); const collisions = []; for (let i = 0; i < sections.length; i += 1) { for (let j = i + 1; j < sections.length; j += 1) { const a = sections[i]; const b = sections[j]; if (a.trasseId === b.trasseId) { continue; } if (a.gebaeude !== b.gebaeude || a.geschoss !== b.geschoss) { continue; } let hit = false; let reason = ''; if (normalizeNodeKey(a.startpunkt) === normalizeNodeKey(b.startpunkt) && normalizeNodeKey(a.endpunkt) === normalizeNodeKey(b.endpunkt)) { hit = true; reason = 'Gleicher Abschnittsverlauf'; } if (!hit && a.corridor && b.corridor && a.corridor === b.corridor) { hit = true; reason = 'Gleicher Korridor / potenzielle Überschneidung'; } const hasCoords = [a.startX, a.startY, a.endX, a.endY, b.startX, b.startY, b.endX, b.endY].every(function(value) { return Number.isFinite(toNumber(value, NaN)); }); if (!hit && hasCoords) { const p1 = { x: toNumber(a.startX, 0), y: toNumber(a.startY, 0) }; const p2 = { x: toNumber(a.endX, 0), y: toNumber(a.endY, 0) }; const p3 = { x: toNumber(b.startX, 0), y: toNumber(b.startY, 0) }; const p4 = { x: toNumber(b.endX, 0), y: toNumber(b.endY, 0) }; if (lineSegmentsIntersect(p1, p2, p3, p4)) { hit = true; reason = 'Geometrische Kreuzung'; } } if (hit) { collisions.push({ id: createUid('collision'), trasseA: a.trasseId, trasseB: b.trasseId, sectionA: a.id, sectionB: b.id, labelA: a.trasseBezeichnung + ' / ' + a.bezeichnung, labelB: b.trasseBezeichnung + ' / ' + b.bezeichnung, reason: reason, gebaeude: a.gebaeude, geschoss: a.geschoss }); } } } return collisions; } function derivePruefstatusFromRecords(kabel) { const tests = state.pruefungen.filter(function(item) { return String(item.kabelId) === String(kabel.id); }); if (!tests.length) { return kabel.pruefstatus || 'ungeplant'; } if (tests.some(function(item) { return item.status === 'maengel'; })) { return 'maengel'; } if (tests.some(function(item) { return item.status === 'freigegeben'; })) { return 'freigegeben'; } if (tests.some(function(item) { return item.status === 'geprueft'; })) { return 'geprueft'; } if (tests.some(function(item) { return item.status === 'geplant'; })) { return 'geplant'; } return kabel.pruefstatus || 'ungeplant'; } function deriveKabelStatusFromData(kabel) { const installRecords = state.verlegungen.filter(function(item) { return String(item.kabelId) === String(kabel.id); }); if (kabel.pruefstatus === 'freigegeben') { return 'freigegeben'; } if (kabel.pruefstatus === 'maengel') { return 'maengel'; } if (installRecords.length && installRecords.some(function(item) { return item.anschlussBestaetigungA && item.anschlussBestaetigungB; })) { return 'verlegt'; } if (installRecords.length) { return kabel.status === 'in_verlegung' ? 'in_verlegung' : 'verlegt'; } if (kabel.route) { return kabel.status === 'in_verlegung' ? 'in_verlegung' : 'geplant'; } return kabel.status || 'ungeplant'; } function calculateStatusAmpel() { const collisions = state.cache.collisions || detectTrasseCollisions(); const avgFill = calculateAverageFillRate(); const criticalOverload = state.trassen.some(function(item) { return getTrasseFillRate(item.id) > getTrasseMaxFillRate(item); }); const defectTests = state.pruefungen.some(function(item) { return item.status === 'maengel'; }); const overdueMilestones = calculateOverdueMilestones(); if (criticalOverload || defectTests || overdueMilestones.length > 0) { return { value: 'rot', reasons: [ criticalOverload ? 'Mindestens eine Trasse ist überfüllt.' : '', defectTests ? 'Offene Mängel in Prüfungen vorhanden.' : '', overdueMilestones.length ? 'Überfällige Zug- oder Prüftermine vorhanden.' : '' ].filter(Boolean) }; } if (collisions.length || avgFill >= state.rules.overloadWarningThreshold || calculateOpenTestsCount() > 0) { return { value: 'gelb', reasons: [ collisions.length ? 'Kollisionen oder Parallelführungen sind zu prüfen.' : '', avgFill >= state.rules.overloadWarningThreshold ? 'Durchschnittliche Trassenauslastung ist hoch.' : '', calculateOpenTestsCount() > 0 ? 'Offene Prüfungen und Freigaben vorhanden.' : '' ].filter(Boolean) }; } return { value: 'gruen', reasons: ['Keine kritischen Auffälligkeiten im aktuellen Datenbestand.'] }; } function buildDashboardSnapshot() { if (state.cache.dashboard) { return state.cache.dashboard; } const lengths = calculateCableLengthTotals(); const avgFill = calculateAverageFillRate(); const openTests = calculateOpenTestsCount(); const pullsThisWeek = calculateThisWeekPullCount(); const buildingProgress = sortBy(calculateBuildingProgress(), function(item) { return item.label; }, 'asc'); const floorProgress = sortBy(calculateFloorProgress(), function(item) { return item.label; }, 'asc'); const trasseProgress = sortBy(calculateTrasseProgress(), function(item) { return item.fillRate; }, 'desc'); const statusAmpel = calculateStatusAmpel(); const upcoming = calculateUpcomingMilestones(); const overdue = calculateOverdueMilestones(); const collisions = detectTrasseCollisions(); state.cache.dashboard = { lengths: lengths, avgFill: avgFill, openTests: openTests, pullsThisWeek: pullsThisWeek, buildingProgress: buildingProgress, floorProgress: floorProgress, trasseProgress: trasseProgress, statusAmpel: statusAmpel, upcoming: upcoming, overdue: overdue, collisions: collisions }; state.cache.collisions = collisions; return state.cache.dashboard; } function refreshDerivedData() { state.kabel.forEach(function(cable) { cable.minBiegeradiusMm = getCableMinBendingRadiusMm(cable); cable.maxZugkraftN = getCableMaxPullForceN(cable); if (!cable.route) { const routeLength = getCableLengthOnBelegungen(cable.id); if (routeLength > 0) { const belegungen = getBelegungenForKabel(cable.id); const sections = belegungen.map(function(item) { return getSectionById(item.sectionId); }).filter(Boolean); const trasseIds = unique(belegungen.map(function(item) { return item.trasseId; })); const trassen = trasseIds.map(function(trasseId) { const trasse = getTrasseById(trasseId); return trasse ? trasse.bezeichnung : trasseId; }); cable.route = { startpunkt: cable.startpunkt, endpunkt: cable.endpunkt, nodeKeys: unique(sections.reduce(function(out, section) { out.push(normalizeNodeKey(section.startpunkt)); out.push(normalizeNodeKey(section.endpunkt)); return out; }, [])), sectionIds: belegungen.map(function(item) { return item.sectionId; }), trasseIds: trasseIds, trassen: trassen, lengthM: round(routeLength, 1), warnings: unique(belegungen.reduce(function(out, item) { return out.concat(ensureArray(item.warnings)); }, [])), blocking: [] }; } } if (cable.route) { const allowanceStart = toNumber(cable.zugabeStartM, getCableDefaultAllowance(cable)); const allowanceEnd = toNumber(cable.zugabeEndM, getCableDefaultAllowance(cable)); cable.laengeGeplantM = round(toNumber(cable.route.lengthM, 0) + allowanceStart + allowanceEnd, 1); } cable.pruefstatus = derivePruefstatusFromRecords(cable); cable.status = deriveKabelStatusFromData(cable); }); state.milestones = deriveMilestones(); clearCache(); saveLocalCache(); } function getFilterValues() { return Object.assign({}, DEFAULT_FILTERS, state.filters || {}); } function itemMatchesSearch(item, fields) { const search = normalizeString(state.filters.suche); if (!search) { return true; } return fields.some(function(field) { return normalizeString(item[field]).indexOf(search) !== -1; }); } function getFilteredTrassen() { const filters = getFilterValues(); return state.trassen.filter(function(item) { if (filters.gebaeude && item.gebaeude !== filters.gebaeude) { return false; } if (filters.geschoss && item.geschoss !== filters.geschoss) { return false; } if (filters.trassentyp && item.typ !== filters.trassentyp) { return false; } if (filters.status && item.status !== filters.status) { return false; } if (!itemMatchesSearch(item, ['bezeichnung', 'notizen'])) { return false; } return true; }); } function getFilteredKabel() { const filters = getFilterValues(); return state.kabel.filter(function(item) { if (filters.gebaeude && item.gebaeude !== filters.gebaeude) { return false; } if (filters.geschoss && item.geschoss !== filters.geschoss) { return false; } if (filters.kabeltyp && item.kabeltyp !== filters.kabeltyp) { return false; } if (filters.kategorie && item.kategorie !== filters.kategorie) { return false; } if (filters.status && calculateCableStatus(item) !== filters.status) { return false; } if (filters.reserveOnly && !item.reserve) { return false; } if (!itemMatchesSearch(item, ['nummer', 'startpunkt', 'endpunkt', 'hersteller', 'notizen'])) { return false; } return true; }); } function getFilteredPruefungen() { const filters = getFilterValues(); return state.pruefungen.filter(function(item) { const cable = getKabelById(item.kabelId); if (filters.gebaeude && cable && cable.gebaeude !== filters.gebaeude) { return false; } if (filters.geschoss && cable && cable.geschoss !== filters.geschoss) { return false; } if (filters.kabeltyp && cable && cable.kabeltyp !== filters.kabeltyp) { return false; } if (filters.status && item.status !== filters.status) { return false; } if (state.filters.suche) { const haystack = [ cable ? cable.nummer : '', item.pruefart, item.notizen, item.freigegebenVon ].join(' '); if (normalizeString(haystack).indexOf(normalizeString(state.filters.suche)) === -1) { return false; } } return true; }); } function getFilteredVerlegungen() { const filters = getFilterValues(); return state.verlegungen.filter(function(item) { const cable = getKabelById(item.kabelId); if (filters.gebaeude && cable && cable.gebaeude !== filters.gebaeude) { return false; } if (filters.geschoss && cable && cable.geschoss !== filters.geschoss) { return false; } if (filters.status && cable && cable.status !== filters.status) { return false; } if (state.filters.suche) { const haystack = [ cable ? cable.nummer : '', item.team, item.protokolltyp, item.notizen ].join(' '); if (normalizeString(haystack).indexOf(normalizeString(state.filters.suche)) === -1) { return false; } } return true; }); } function renderHeader() { const lastRefresh = state.ui.lastRefreshAt ? formatDateTime(state.ui.lastRefreshAt) : '—'; return [ '
', '
', '
', '

BauGenio v4 — Kabelmanagement & Trassenplanung

', '

Planung, Belegung, Verlegung, Prüfung und Bestandsdokumentation in einem Modul.

', '
', 'Stand: ' + escapeHtml(lastRefresh) + '', 'Datensatz: ' + escapeHtml(state.demoMode ? 'Demo/Cache' : 'API') + '', 'Trassen: ' + escapeHtml(String(state.trassen.length)) + '', 'Kabel: ' + escapeHtml(String(state.kabel.length)) + '', '
', '
', '
', '', '', '', '
', '
', '
' ].join(''); } function renderAlerts() { if (!state.ui.alerts.length) { return ''; } return [ '
', state.ui.alerts.map(function(item) { const type = item.type === 'error' ? 'danger' : item.type; return [ '' ].join(''); }).join(''), '
' ].join(''); } function renderFilterBar() { const filters = getFilterValues(); const buildings = unique(state.trassen.map(function(item) { return item.gebaeude; }).concat(state.kabel.map(function(item) { return item.gebaeude; })).concat(state.struktur.gebaeude || [])); const floors = unique(state.trassen.map(function(item) { return item.geschoss; }).concat(state.kabel.map(function(item) { return item.geschoss; })).concat(state.struktur.geschosse || [])); const cableTypes = getAllKabelTypeOptions(); return [ '
', '
', '
', '', '', '
', '
', '', '', '
', '
', '', '', '
', '
', '', '', '
', '
', '', '', '
', '
', '', '', '
', '
', '', '', '
', '
', '
', '', '', '
', '
', '
', '', '
', '
', '
' ].join(''); } function renderTabs() { const counts = { dashboard: '', trassen: String(getFilteredTrassen().length), kabel: String(getFilteredKabel().length), belegung: String(state.belegungen.length), verlegung: String(getFilteredVerlegungen().length), pruefung: String(getFilteredPruefungen().length), berichte: '' }; const items = [ { key: TAB_KEYS.DASHBOARD, label: 'Dashboard' }, { key: TAB_KEYS.TRASSEN, label: 'Trassen' }, { key: TAB_KEYS.KABEL, label: 'Kabelliste' }, { key: TAB_KEYS.BELEGUNG, label: 'Belegung & Routen' }, { key: TAB_KEYS.VERLEGUNG, label: 'Verlegedoku' }, { key: TAB_KEYS.PRUEFUNG, label: 'Prüfungen' }, { key: TAB_KEYS.BERICHTE, label: 'Berichte & Export' } ]; return [ '
', '', '
' ].join(''); } function renderEmptyState(title, text, buttonLabel, action) { return [ '
', '
' + escapeHtml(title) + '
', '

' + escapeHtml(text) + '

', buttonLabel && action ? '' : '', '
' ].join(''); } function renderProgressBlock(items, emptyText) { if (!items.length) { return '
' + escapeHtml(emptyText || 'Keine Daten vorhanden.') + '
'; } return [ '
', items.map(function(item) { const width = clamp(toNumber(item.progress, 0) * 100, 0, 100); return [ '
', '
' + escapeHtml(item.label) + '
' + escapeHtml(formatLength(item.installed)) + ' / ' + escapeHtml(formatLength(item.planned)) + '
', '
', '
', '
', '
' + escapeHtml(formatPercent(item.progress, 1)) + '
', '
' ].join(''); }).join(''), '
' ].join(''); } function renderMilestoneTable(items, emptyText) { if (!items.length) { return '
' + escapeHtml(emptyText || 'Keine Termine vorhanden.') + '
'; } return [ '
', '', '', '', items.map(function(item) { return ''; }).join(''), '', '
DatumTypBezeichnungStatus
' + escapeHtml(formatDate(item.datum)) + '' + escapeHtml(item.typ) + '' + escapeHtml(item.titel) + '' + getBadge(item.status) + '
', '
' ].join(''); } function renderCollisionList(collisions) { if (!collisions.length) { return '
Keine Kollisionen erkannt.
'; } return [ '
', collisions.map(function(item) { return [ '
', '
', '
' + escapeHtml(item.labelA) + '
vs. ' + escapeHtml(item.labelB) + '
', '
Prüfen
', '
', '
' + escapeHtml(item.reason) + '
', '
' + escapeHtml(item.gebaeude + ' / ' + item.geschoss) + '
', '
' ].join(''); }).join(''), '
' ].join(''); } function renderDashboard() { const snapshot = buildDashboardSnapshot(); const ampelMeta = AMPEL_META[snapshot.statusAmpel.value] || AMPEL_META.gruen; const topTrassen = sortBy(snapshot.trasseProgress, function(item) { return item.fillRate; }, 'desc').slice(0, 6); return [ '
', '
', '
', '
Kabelmeter gesamt
', '
' + escapeHtml(formatLength(snapshot.lengths.planned)) + '
', '
Verlegt: ' + escapeHtml(formatLength(snapshot.lengths.installed)) + ' · Geprüft: ' + escapeHtml(formatLength(snapshot.lengths.checked)) + '
', '
', '
', '
', '
', '
Trassen-Auslastung Ø
', '
' + escapeHtml(formatPercent(snapshot.avgFill, 1)) + '
', '
Grenzwertsystem project rule basiert
', '
', '
', '
', '
', '
Offene Prüfungen
', '
' + escapeHtml(String(snapshot.openTests)) + '
', '
inkl. Nachprüfungen und Freigaben
', '
', '
', '
', '
', '
Kabelzugtermine diese Woche
', '
' + escapeHtml(String(snapshot.pullsThisWeek)) + '
', '
Planungsfokus für Bauleitung
', '
', '
', '
', '
', '
', '
', '
Statusampel
', '
Gesamtsicht auf Kabel- und Trassenlage
', '
', '
' + escapeHtml(ampelMeta.icon) + '' + escapeHtml(ampelMeta.label) + '
', '
', '
', snapshot.statusAmpel.reasons.map(function(reason) { return '
' + escapeHtml(reason) + '
'; }).join('') || '
Keine Hinweise.
', '
', '
', '
', '
', '
', '
', '
Fortschritt nach Gebäude
Geplante vs. verlegte Kabelmeter
', '
', renderProgressBlock(snapshot.buildingProgress, 'Keine Gebäudedaten vorhanden.'), '
', '
', '
', '
', '
', '
Fortschritt nach Geschoss
Abarbeitung pro Ebene
', '
', renderProgressBlock(snapshot.floorProgress, 'Keine Geschossdaten vorhanden.'), '
', '
', '
', '
', '
', '
Top-Trassen nach Auslastung
Priorisierung für Entlastung und Neuplanung
', '
', renderProgressBlock(topTrassen.map(function(item) { return { label: item.label, planned: item.maxFillRate * 100, installed: item.fillRate * 100, progress: item.maxFillRate ? item.fillRate / item.maxFillRate : 0 }; }), 'Keine Trassendaten vorhanden.'), '
', '
', '
', '
', '
Kabelmengen nach Typ
Längenverteilung
', '', '
', '
', '
', '
', '
Trassenauslastung
Top 8 Trassen
', '', '
', '
', '
', '
', '
Verlegefortschritt
Planned vs. installed
', '', '
', '
', '
', '
', '
', '
Nächste Meilensteine
Zugtermine, Prüfungen, Nachprüfungen
', '
', renderMilestoneTable(snapshot.upcoming.slice(0, 10), 'Keine anstehenden Meilensteine in den nächsten Wochen.'), '
', '
', '
', '
', '
', '
Überfällige Themen
Direkter Handlungsbedarf
', '
', renderMilestoneTable(snapshot.overdue.slice(0, 10), 'Keine überfälligen Themen erkannt.'), '
', '
', '
', '
', '
', '
Kollisionen & Überschneidungen
Automatische heuristische Prüfung auf Abschnitts- und Korridorebene
', '
', renderCollisionList(snapshot.collisions.slice(0, 12)), '
', '
', '
' ].join(''); } function renderSectionRowsForTrasseForm(sections) { const items = sections && sections.length ? sections : [ { id: createUid('section'), bezeichnung: '', startpunkt: '', endpunkt: '', laengeM: '', corridor: '', startX: '', startY: '', endX: '', endY: '', verknuepftMit: [] } ]; return items.map(function(section, index) { return [ '', '', '', '', '', '', '', '
', '', '', '
', '
', '', '', '
', '', '', '' ].join(''); }).join(''); } function buildTrasseFormHtml(trasse) { const item = trasse || { bezeichnung: '', typ: 'kabelrinne', gebaeude: state.struktur.gebaeude[0] || '', geschoss: state.struktur.geschosse[0] || '', nutzbreiteMm: 300, nutzhoeheMm: 60, tragfaehigkeitKgM: 40, material: 'stahl_verzinkt', brandschutzklasse: 'ohne', trennstegVorhanden: false, status: 'geplant', notizen: '', abschnitte: [] }; return [ '
', '', '
', '
Stammdaten
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
Trassenabschnitte
', '', '
', '
', '', '', '', renderSectionRowsForTrasseForm(item.abschnitte), '', '
BezeichnungStartpunktEndpunktLänge [m]KorridorKoordinaten
', '
', '', '
', '' + createSimpleOptionList(state.struktur.gebaeude || [], '', false) + '', '' + createSimpleOptionList(state.struktur.geschosse || [], '', false) + '', '', '
' ].join(''); } function buildKabelFormHtml(cable) { const item = cable || { nummer: '', kategorie: 'starkstrom', kabeltyp: 'nym', querschnittMm2: '', adern: '', laengeGeplantM: '', laengeGemessenM: '', hersteller: '', farbe: 'grau', startpunkt: '', endpunkt: '', gebaeude: state.struktur.gebaeude[0] || '', geschoss: state.struktur.geschosse[0] || '', reserve: false, ersatz: false, status: 'ungeplant', pruefstatus: 'ungeplant', zugabeStartM: 1.5, zugabeEndM: 1.5, zugtermin: '', notizen: '' }; return [ '
', '', '
', '
Kabelstammdaten
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
Führung & Zuordnung
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '' + createSimpleOptionList(state.struktur.gebaeude || [], '', false) + '', '' + createSimpleOptionList(state.struktur.geschosse || [], '', false) + '', '' + createSimpleOptionList(unique((state.struktur.verteiler || []).concat(state.struktur.patchfelder || []).concat(state.struktur.raeume || []).concat(getAllSections().map(function(section) { return section.startpunkt; })).concat(getAllSections().map(function(section) { return section.endpunkt; }))), '', false) + '', '', '
' ].join(''); } function buildVerlegungFormHtml(record) { const item = record || { kabelId: state.kabel[0] ? state.kabel[0].id : '', datum: todayISO(), team: TEAM_OPTIONEN[0], wetter: WETTER_OPTIONEN[0], protokolltyp: 'Verlegeprotokoll', fotos: [], zugkraftN: '', temperaturC: '', zugrichtung: '', biegeradiusMm: '', isolationMOhm: '', durchgangOk: false, mantelpruefungOk: false, sichtpruefungOk: false, anschlussBestaetigungA: false, anschlussBestaetigungB: false, notizen: '' }; return [ '
', '', '
', '
Verlegeprotokoll
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '', '
' ].join(''); } function buildPruefungFormHtml(record) { const item = record || { kabelId: state.kabel[0] ? state.kabel[0].id : '', planDatum: todayISO(), pruefDatum: '', pruefart: 'vde_0100_600', status: 'geplant', messwerte: { isolationMOhm: '', schleifenimpedanzOhm: '', rcdMs: '', daempfungDb: '', otdr: '', fluke: '', catZertifizierung: '' }, maengel: [], nachprueftermin: '', freigegebenVon: '', notizen: '' }; return [ '
', '', '
', '
Prüfplan & Messprotokoll
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '
', '', '
' ].join(''); } function renderSelectedTrasseDetail(trasse) { if (!trasse) { return renderEmptyState('Keine Trasse ausgewählt', 'Wähle in der Tabelle eine Trasse aus oder lege eine neue Trasse an.'); } const assignments = getBelegungenForTrasse(trasse.id); const fillRate = getTrasseFillRate(trasse.id); const maxFillRate = getTrasseMaxFillRate(trasse); const usedArea = getTrasseUsedAreaMm2(trasse.id); const capacity = getTrasseCapacityAreaMm2(trasse); const overloaded = fillRate > maxFillRate; return [ '
', '
', '
', '
' + escapeHtml(trasse.bezeichnung) + '
', '
', '' + escapeHtml(getTrasseTypMeta(trasse.typ).label) + '', '' + escapeHtml(trasse.gebaeude + ' / ' + trasse.geschoss) + '', getBadge(trasse.status), '
', '
', '
', '', '', '
', '
', '
', '
', '
Material
' + escapeHtml((MATERIALIEN.find(function(item) { return item.value === trasse.material; }) || {}).label || trasse.material || '—') + '
', '
Brandschutz
' + escapeHtml((BRANDSCHUTZKLASSEN.find(function(item) { return item.value === trasse.brandschutzklasse; }) || {}).label || trasse.brandschutzklasse || '—') + '
', '
Nutzquerschnitt
' + escapeHtml(String(trasse.nutzbreiteMm)) + ' × ' + escapeHtml(String(trasse.nutzhoeheMm)) + ' mm
', '
Tragfähigkeit
' + escapeHtml(String(trasse.tragfaehigkeitKgM || '—')) + ' kg/m
', '
Belegte Fläche
' + escapeHtml(formatArea(usedArea)) + ' / ' + escapeHtml(formatArea(capacity)) + '
', '
Füllgrad
' + escapeHtml(formatPercent(fillRate, 1)) + ' von max. ' + escapeHtml(formatPercent(maxFillRate, 0)) + (overloaded ? ' · ÜBERLAST' : '') + '
', '
Trennsteg
' + escapeHtml(trasse.trennstegVorhanden ? 'Ja' : 'Nein') + '
', '
Notizen
' + escapeHtml(trasse.notizen || '—') + '
', '
', '
', '
Abschnitte
', '
', '', '', '', ensureArray(trasse.abschnitte).map(function(section) { return ''; }).join('') || '', '', '
BezeichnungStartEndeLängeKorridor
' + escapeHtml(section.bezeichnung || '—') + '' + escapeHtml(section.startpunkt || '—') + '' + escapeHtml(section.endpunkt || '—') + '' + escapeHtml(formatLength(estimateSectionLength(section))) + '' + escapeHtml(section.corridor || '—') + '
Keine Abschnitte vorhanden.
', '
', '
Zugeordnete Kabel
', '
', '', '', '', assignments.map(function(assignment) { const cable = getKabelById(assignment.kabelId); return ''; }).join('') || '', '', '
KabelTypMeterStatusHinweise
' + escapeHtml(cable ? cable.nummer : assignment.kabelId) + '' + escapeHtml(cable ? getKabelTypLabel(cable.kategorie, cable.kabeltyp) : '—') + '' + escapeHtml(formatLength(assignment.meter)) + '' + getBadge(cable ? cable.status : 'ungeplant') + '' + escapeHtml(ensureArray(assignment.warnings).join(', ') || '—') + '
Keine Kabel belegt.
', '
', '
' ].join(''); } function renderTrassen() { const items = getFilteredTrassen(); const selected = getTrasseById(state.ui.selectedTrasseId) || items[0] || null; if (selected && !state.ui.selectedTrasseId) { state.ui.selectedTrasseId = selected.id; } return [ '
', '
', '
', '
', '
Trassenverwaltung
CRUD, Füllgrad, Abschnittsführung und Routenknoten
', '
', '', '
', '
', items.length ? [ '
', '', '', '', items.map(function(item) { const active = String(state.ui.selectedTrasseId) === String(item.id) ? 'table-active' : ''; const fillRate = getTrasseFillRate(item.id); const maxFillRate = getTrasseMaxFillRate(item); return [ '', '', '', '', '', '', '', '', '', '' ].join(''); }).join(''), '', '
BezeichnungTypGebäude/GeschossAbschnitteKapazitätFüllgradStatus
' + escapeHtml(getTrasseTypMeta(item.typ).label) + '' + escapeHtml(item.gebaeude + ' / ' + item.geschoss) + '' + escapeHtml(String(ensureArray(item.abschnitte).length)) + '' + escapeHtml(formatArea(getTrasseCapacityAreaMm2(item))) + '
' + escapeHtml(formatPercent(fillRate, 1)) + ' / max ' + escapeHtml(formatPercent(maxFillRate, 0)) + '
' + getBadge(item.status) + '
', '
' ].join('') : renderEmptyState('Keine Trassen gefunden', 'Mit dem aktuellen Filter gibt es keine Trassen.', 'Neue Trasse', 'create-trasse'), '
', '
', '
', renderSelectedTrasseDetail(selected), '
', '
' ].join(''); } function renderSelectedKabelDetail(cable) { if (!cable) { return renderEmptyState('Kein Kabel ausgewählt', 'Wähle in der Kabelliste ein Kabel aus oder lege ein neues an.'); } const tests = state.pruefungen.filter(function(item) { return String(item.kabelId) === String(cable.id); }); const installs = state.verlegungen.filter(function(item) { return String(item.kabelId) === String(cable.id); }); const routeWarnings = cable.route ? ensureArray(cable.route.warnings).concat(ensureArray(cable.route.blocking)) : []; return [ '
', '
', '
', '
' + escapeHtml(cable.nummer) + '
', '
', '' + escapeHtml(getKategorieMeta(cable.kategorie).label) + '', '' + escapeHtml(getKabelTypLabel(cable.kategorie, cable.kabeltyp)) + '', getBadge(cable.status), getBadge(cable.pruefstatus), '
', '
', '
', '', '', '
', '
', '
', '
', '
Führung
' + escapeHtml(cable.startpunkt + ' → ' + cable.endpunkt) + '
', '
Gebäude / Geschoss
' + escapeHtml((cable.gebaeude || '—') + ' / ' + (cable.geschoss || '—')) + '
', '
Hersteller / Farbe
' + escapeHtml((cable.hersteller || '—') + ' / ' + (cable.farbe || '—')) + '
', '
Querschnitt / Adern
' + escapeHtml(formatNumber(cable.querschnittMm2, 1)) + ' mm² / ' + escapeHtml(String(cable.adern || '—')) + '
', '
Länge geplant
' + escapeHtml(formatLength(cable.laengeGeplantM || 0)) + '
', '
Länge gemessen
' + escapeHtml(formatLength(cable.laengeGemessenM || 0)) + '
', '
Biegeradius min.
' + escapeHtml(String(cable.minBiegeradiusMm || getCableMinBendingRadiusMm(cable))) + ' mm
', '
Zugkraft max.
' + escapeHtml(String(cable.maxZugkraftN || getCableMaxPullForceN(cable))) + ' N
', '
Zugtermin
' + escapeHtml(formatDate(cable.zugtermin)) + '
', '
Flags
' + escapeHtml([cable.reserve ? 'Reserve' : '', cable.ersatz ? 'Ersatz' : ''].filter(Boolean).join(', ') || '—') + '
', '
Route
' + escapeHtml(cable.route ? ensureArray(cable.route.trassen).join(' → ') : 'Noch nicht geplant') + '
', '
Notizen
' + escapeHtml(cable.notizen || '—') + '
', '
', '
', '
Routenhinweise
', routeWarnings.length ? '
' + routeWarnings.map(function(text) { return '
' + escapeHtml(text) + '
'; }).join('') + '
' : '
Keine Routenhinweise vorhanden.
', '
', '
Verlegeprotokolle
', '
', '', '', '', installs.map(function(entry) { return ''; }).join('') || '', '', '
DatumTypTeamBemerkung
' + escapeHtml(formatDate(entry.datum)) + '' + escapeHtml(entry.protokolltyp || '—') + '' + escapeHtml(entry.team || '—') + '' + escapeHtml(entry.notizen || '—') + '
Keine Protokolle vorhanden.
', '
', '
Prüfungen
', '
', '', '', '', tests.map(function(entry) { return ''; }).join('') || '', '', '
PlanArtStatusNotiz
' + escapeHtml(formatDate(entry.planDatum)) + '' + escapeHtml(getPruefartLabel(entry.pruefart)) + '' + getBadge(entry.status) + '' + escapeHtml(entry.notizen || '—') + '
Keine Prüfungen vorhanden.
', '
', '
' ].join(''); } function renderKabel() { const items = getFilteredKabel(); const selected = getKabelById(state.ui.selectedKabelId) || items[0] || null; if (selected && !state.ui.selectedKabelId) { state.ui.selectedKabelId = selected.id; } return [ '
', '
', '
', '
', '
Kabelliste & Kabelführung
Zentrale Projektverkabelung mit Längen, Start-/Endpunkten und Route
', '
', '', '', '
', '
', items.length ? [ '
', '', '', '', items.map(function(item) { const active = String(state.ui.selectedKabelId) === String(item.id) ? 'table-active' : ''; const routeText = item.route ? ensureArray(item.route.trassen).join(' → ') : '—'; const flags = [item.reserve ? 'R' : '', item.ersatz ? 'E' : ''].filter(Boolean).join('/'); return [ '', '', '', '', '', '', '', '', '' ].join(''); }).join(''), '', '
Kabelnr.Kategorie/TypStart → EndeLängeRouteStatus
' + (flags ? '
Flags: ' + escapeHtml(flags) + '
' : '') + '
' + escapeHtml(getKategorieMeta(item.kategorie).label) + '
' + escapeHtml(getKabelTypLabel(item.kategorie, item.kabeltyp)) + '
' + escapeHtml(item.startpunkt + ' → ' + item.endpunkt) + '
' + escapeHtml(formatLength(item.laengeGeplantM || 0)) + '
gemessen: ' + escapeHtml(formatLength(item.laengeGemessenM || 0)) + '
' + escapeHtml(routeText) + '
' + getBadge(item.status) + getBadge(item.pruefstatus) + '
', '
' ].join('') : renderEmptyState('Keine Kabel gefunden', 'Mit dem aktuellen Filter ist die Kabelliste leer.', 'Neues Kabel', 'create-kabel'), '
', '
', '
', renderSelectedKabelDetail(selected), '
', '
' ].join(''); } function renderUnassignedCableCards() { const items = getFilteredKabel().filter(function(item) { return !getBelegungenForKabel(item.id).length; }); if (!items.length) { return '
Alle gefilterten Kabel haben bereits eine Belegung.
'; } return items.map(function(item) { return [ '
', '
', '
' + escapeHtml(item.nummer) + '
' + escapeHtml(getKabelTypLabel(item.kategorie, item.kabeltyp)) + '
', '
' + getBadge(item.status) + '
', '
', '
' + escapeHtml(item.startpunkt + ' → ' + item.endpunkt) + '
', '
', '' + escapeHtml(formatLength(item.laengeGeplantM || 0)) + '', '' + escapeHtml(getKategorieMeta(item.kategorie).label) + '', '
', '
' ].join(''); }).join(''); } function renderTrasseBoards() { const trassen = getFilteredTrassen(); if (!trassen.length) { return '
Keine Trassen im aktuellen Filter.
'; } return trassen.map(function(trasse) { const assignments = getBelegungenForTrasse(trasse.id); const fillRate = getTrasseFillRate(trasse.id); const maxFillRate = getTrasseMaxFillRate(trasse); const overloaded = fillRate > maxFillRate; return [ '
', '
', '
' + escapeHtml(trasse.bezeichnung) + '
' + escapeHtml(getTrasseTypMeta(trasse.typ).label + ' · ' + trasse.gebaeude + ' / ' + trasse.geschoss) + '
', '
' + getBadge(trasse.status) + (overloaded ? 'Überlast' : '') + '
', '
', '
', '
', '
Füllgrad: ' + escapeHtml(formatPercent(fillRate, 1)) + ' / max ' + escapeHtml(formatPercent(maxFillRate, 0)) + '
', '
', '
', assignments.map(function(assignment) { const cable = getKabelById(assignment.kabelId); return [ '
', '
', '
' + escapeHtml(cable ? cable.nummer : assignment.kabelId) + '
' + escapeHtml(cable ? getKabelTypLabel(cable.kategorie, cable.kabeltyp) : '—') + '
', '
', '
', '
Abschnitt: ' + escapeHtml((getSectionById(assignment.sectionId) || {}).bezeichnung || '—') + ' · ' + escapeHtml(formatLength(assignment.meter)) + '
', ensureArray(assignment.warnings).length ? '
' + ensureArray(assignment.warnings).map(function(text) { return '' + escapeHtml(text) + ''; }).join('') + '
' : '', '
' ].join(''); }).join('') || '
Kabel hierher ziehen oder per Auto-Route belegen.
', '
', '
' ].join(''); }).join(''); } function renderBelegungsmatrix() { const trassen = getFilteredTrassen(); const cables = getFilteredKabel().slice(0, 20); if (!trassen.length || !cables.length) { return '
Zu wenig Daten für die Belegungsmatrix.
'; } return [ '
', '', '' + trassen.map(function(trasse) { return ''; }).join('') + '', '', cables.map(function(cable) { return [ '', '', trassen.map(function(trasse) { const assigned = getBelegungenForKabel(cable.id).filter(function(item) { return String(item.trasseId) === String(trasse.id); }); if (!assigned.length) { return ''; } const meter = sumBy(assigned, function(item) { return item.meter; }); const warnings = unique(assigned.reduce(function(out, item) { return out.concat(ensureArray(item.warnings)); }, [])); const badge = warnings.length ? '
Hinweis
' : ''; return ''; }).join(''), '' ].join(''); }).join(''), '', '
Kabel \ Trasse' + escapeHtml(trasse.bezeichnung) + '
' + escapeHtml(cable.nummer) + '
' + escapeHtml(formatLength(meter)) + '
' + badge + '
', '
' ].join(''); } function renderBelegung() { const collisions = state.cache.collisions || detectTrasseCollisions(); const warnings = getFilteredTrassen().filter(function(item) { return getTrasseFillRate(item.id) > getTrasseMaxFillRate(item); }); return [ '
', '
', '
', '
Nicht zugewiesene Kabel
Drag & Drop auf Trassenboards
', renderUnassignedCableCards(), '
', '
', '
', '
', '
', '
Trassenbelegungsplanung
Echtzeit-Füllgrad und Trennungswarnungen
', '
', '', '
', '
', renderTrasseBoards(), '
', '
', '
', '
', '
Risiken & Kollisionen
Planungswarnungen für Bauleitung
', warnings.length ? warnings.map(function(item) { return '
' + escapeHtml(item.bezeichnung) + '
Füllgrad: ' + escapeHtml(formatPercent(getTrasseFillRate(item.id), 1)) + ' / max ' + escapeHtml(formatPercent(getTrasseMaxFillRate(item), 0)) + '
'; }).join('') : '
Keine Überlastung erkannt.
', collisions.length ? renderCollisionList(collisions.slice(0, 6)) : '
Keine Kollisionen erkannt.
', '
', '
', '
', '
', '
Belegungsmatrix
Welches Kabel läuft durch welche Trasse
', renderBelegungsmatrix(), '
', '
', '
' ].join(''); } function renderVerlegung() { const items = getFilteredVerlegungen(); const selected = getVerlegungById(state.ui.selectedVerlegungId) || items[0] || null; if (selected && !state.ui.selectedVerlegungId) { state.ui.selectedVerlegungId = selected.id; } const detail = selected ? [ '
', '
', '
' + escapeHtml((getKabelById(selected.kabelId) || {}).nummer || selected.kabelId) + '
' + escapeHtml(selected.protokolltyp || '—') + '
', '
', '
', '
', '
', '
Datum
' + escapeHtml(formatDate(selected.datum)) + '
', '
Team
' + escapeHtml(selected.team || '—') + '
', '
Wetter
' + escapeHtml(selected.wetter || '—') + '
', '
Zugkraft
' + escapeHtml(selected.zugkraftN ? formatForce(selected.zugkraftN) : '—') + '
', '
Temperatur
' + escapeHtml(selected.temperaturC ? formatTemperature(selected.temperaturC) : '—') + '
', '
Zugrichtung
' + escapeHtml(selected.zugrichtung || '—') + '
', '
Biegeradius
' + escapeHtml(selected.biegeradiusMm ? (selected.biegeradiusMm + ' mm') : '—') + '
', '
Isolation
' + escapeHtml(selected.isolationMOhm ? formatMegaOhm(selected.isolationMOhm) : '—') + '
', '
Checklisten
' + escapeHtml([ selected.durchgangOk ? 'Durchgang OK' : 'Durchgang offen', selected.mantelpruefungOk ? 'Mantel OK' : 'Mantel offen', selected.sichtpruefungOk ? 'Sicht OK' : 'Sicht offen' ].join(' · ')) + '
', '
Anschlussbestätigung
' + escapeHtml((selected.anschlussBestaetigungA ? 'A OK' : 'A offen') + ' · ' + (selected.anschlussBestaetigungB ? 'B OK' : 'B offen')) + '
', '
Fotos
' + (ensureArray(selected.fotos).map(function(photo) { return '📷 ' + escapeHtml(photo) + ''; }).join('') || '—') + '
', '
Notizen
' + escapeHtml(selected.notizen || '—') + '
', '
', '
' ].join('') : renderEmptyState('Kein Protokoll ausgewählt', 'Wähle ein Verlegeprotokoll aus oder lege ein neues an.'); return [ '
', '
', '
', '
', '
Verlegedokumentation
Zugplanung, Messwerte, Fotos, Checklisten, Abschlussprotokolle
', '
', '
', items.length ? [ '
', '', '', '', items.map(function(item) { const cable = getKabelById(item.kabelId); const active = String(state.ui.selectedVerlegungId) === String(item.id) ? 'table-active' : ''; return ''; }).join(''), '', '
KabelDatumTeamTypZugkraftCheckliste
' + escapeHtml(formatDate(item.datum)) + '' + escapeHtml(item.team || '—') + '' + escapeHtml(item.protokolltyp || '—') + '' + escapeHtml(item.zugkraftN ? formatForce(item.zugkraftN) : '—') + '' + escapeHtml((item.sichtpruefungOk ? 'S' : '-') + '/' + (item.mantelpruefungOk ? 'M' : '-') + '/' + (item.durchgangOk ? 'D' : '-')) + '
', '
' ].join('') : renderEmptyState('Keine Verlegeprotokolle vorhanden', 'Lege das erste Protokoll an.', 'Neues Protokoll', 'create-verlegung'), '
', '
', '
', detail, '
', '
' ].join(''); } function renderPruefung() { const items = getFilteredPruefungen(); const selected = getPruefungById(state.ui.selectedPruefungId) || items[0] || null; if (selected && !state.ui.selectedPruefungId) { state.ui.selectedPruefungId = selected.id; } const metrics = { geplant: state.pruefungen.filter(function(item) { return item.status === 'geplant'; }).length, geprueft: state.pruefungen.filter(function(item) { return item.status === 'geprueft'; }).length, freigegeben: state.pruefungen.filter(function(item) { return item.status === 'freigegeben'; }).length, maengel: state.pruefungen.filter(function(item) { return item.status === 'maengel'; }).length }; const detail = selected ? [ '
', '
', '
' + escapeHtml((getKabelById(selected.kabelId) || {}).nummer || selected.kabelId) + '
' + escapeHtml(getPruefartLabel(selected.pruefart)) + '
', '
' + (selected.status !== 'freigegeben' ? '' : '') + '
', '
', '
', '
', '
Plan-Datum
' + escapeHtml(formatDate(selected.planDatum)) + '
', '
Prüfdatum
' + escapeHtml(formatDate(selected.pruefDatum)) + '
', '
Status
' + getBadge(selected.status) + '
', '
Isolationswiderstand
' + escapeHtml(selected.messwerte && selected.messwerte.isolationMOhm ? formatMegaOhm(selected.messwerte.isolationMOhm) : '—') + '
', '
Schleifenimpedanz
' + escapeHtml(selected.messwerte && selected.messwerte.schleifenimpedanzOhm ? formatOhm(selected.messwerte.schleifenimpedanzOhm) : '—') + '
', '
RCD
' + escapeHtml(selected.messwerte && selected.messwerte.rcdMs ? formatNumber(selected.messwerte.rcdMs, 1) + ' ms' : '—') + '
', '
Dämpfung
' + escapeHtml(selected.messwerte && selected.messwerte.daempfungDb ? formatDb(selected.messwerte.daempfungDb) : '—') + '
', '
OTDR
' + escapeHtml(selected.messwerte && selected.messwerte.otdr || '—') + '
', '
Fluke
' + escapeHtml(selected.messwerte && selected.messwerte.fluke || '—') + '
', '
Cat-Zertifizierung
' + escapeHtml(selected.messwerte && selected.messwerte.catZertifizierung || '—') + '
', '
Freigegeben von
' + escapeHtml(selected.freigegebenVon || '—') + '
', '
Nachprüfung
' + escapeHtml(formatDate(selected.nachprueftermin)) + '
', '
Notizen
' + escapeHtml(selected.notizen || '—') + '
', '
', '
', '
Mängel
', ensureArray(selected.maengel).length ? '
' + ensureArray(selected.maengel).map(function(item) { return '
' + escapeHtml(item.beschreibung) + '
Frist: ' + escapeHtml(formatDate(item.frist)) + ' · ' + escapeHtml(item.behoben ? 'Behoben' : 'Offen') + '
'; }).join('') + '
' : '
Keine Mängel erfasst.
', '
' ].join('') : renderEmptyState('Keine Prüfung ausgewählt', 'Wähle eine Prüfung aus oder lege einen Prüfplan an.'); return [ '
', '
Geplant
' + escapeHtml(String(metrics.geplant)) + '
', '
Geprüft
' + escapeHtml(String(metrics.geprueft)) + '
', '
Freigegeben
' + escapeHtml(String(metrics.freigegeben)) + '
', '
Mängel
' + escapeHtml(String(metrics.maengel)) + '
', '
', '
', '
Prüfplan & Inbetriebnahme
VDE-, OTDR- und Fluke-Protokolle mit Mängeltracking
', items.length ? [ '
', '', '', '', items.map(function(item) { const cable = getKabelById(item.kabelId); const active = String(state.ui.selectedPruefungId) === String(item.id) ? 'table-active' : ''; return ''; }).join(''), '', '
KabelArtPlanPrüfdatumStatusMängel
' + escapeHtml(getPruefartLabel(item.pruefart)) + '' + escapeHtml(formatDate(item.planDatum)) + '' + escapeHtml(formatDate(item.pruefDatum)) + '' + getBadge(item.status) + '' + escapeHtml(String(ensureArray(item.maengel).length)) + '
', '
' ].join('') : renderEmptyState('Keine Prüfungen vorhanden', 'Lege den ersten Prüfplan an.', 'Neue Prüfung', 'create-pruefung'), '
', '
', '
', detail, '
', '
' ].join(''); } function renderReports() { const snapshot = buildDashboardSnapshot(); const cards = [ { action: 'export-cable-list-pdf', secondary: 'export-cable-list-csv', title: 'Kabelliste', text: 'PDF-Kabellisten mit technischen Daten, Längen, Start-/Endpunkten und Status.', primaryLabel: 'PDF', secondaryLabel: 'Excel/CSV' }, { action: 'export-occupancy-pdf', secondary: 'export-occupancy-csv', title: 'Trassenbelegungsplan', text: 'Belegungspläne, Füllgrade, Trennungswarnungen und Belegungsmatrix.', primaryLabel: 'PDF', secondaryLabel: 'Excel/CSV' }, { action: 'export-test-pdf', secondary: 'export-test-csv', title: 'Prüfprotokolle', text: 'VDE-, OTDR- und Fluke-Messprotokolle inkl. Mängelstatus.', primaryLabel: 'PDF', secondaryLabel: 'Excel/CSV' }, { action: 'export-material-csv', secondary: 'export-material-pdf', title: 'Materialauszug', text: 'Kabelmeter, Trassenkomponenten und Auszug zur Bestellung.', primaryLabel: 'Excel/CSV', secondaryLabel: 'PDF' }, { action: 'export-asbuilt-pdf', secondary: 'export-asbuilt-csv', title: 'Bestandsdokumentation', text: 'As-built Doku für Gebäudebetrieb und spätere Wartung.', primaryLabel: 'PDF', secondaryLabel: 'Excel/CSV' } ]; return [ '
', cards.map(function(card) { return [ '
', '
', '
' + escapeHtml(card.title) + '
', '

' + escapeHtml(card.text) + '

', '
', '', '', '
', '
', '
' ].join(''); }).join(''), '
', '
', '
Management Summary
Verdichtung der wichtigsten Projektparameter
', '
', '
Kabelmeter geplant
' + escapeHtml(formatLength(snapshot.lengths.planned)) + '
', '
Kabelmeter verlegt
' + escapeHtml(formatLength(snapshot.lengths.installed)) + '
', '
Kabelmeter geprüft
' + escapeHtml(formatLength(snapshot.lengths.checked)) + '
', '
Durchschnittliche Trassenauslastung
' + escapeHtml(formatPercent(snapshot.avgFill, 1)) + '
', '
Offene Prüfungen
' + escapeHtml(String(snapshot.openTests)) + '
', '
Kollisionen
' + escapeHtml(String(snapshot.collisions.length)) + '
', '
Überfällige Themen
' + escapeHtml(String(snapshot.overdue.length)) + '
', '
Statusampel
' + escapeHtml((AMPEL_META[snapshot.statusAmpel.value] || AMPEL_META.gruen).label) + '
', '
', '
', '
', '
', '
', '
Vorschau Materialauszug
Verdichtete Mengen für Einkauf und Logistik
', renderMaterialSummaryTable(), '
', '
', '
' ].join(''); } function renderMaterialSummaryTable() { const summary = {}; state.kabel.forEach(function(item) { const key = item.kategorie + '|' + item.kabeltyp; if (!summary[key]) { summary[key] = { kategorie: getKategorieMeta(item.kategorie).label, kabeltyp: getKabelTypLabel(item.kategorie, item.kabeltyp), meter: 0, anzahl: 0 }; } summary[key].meter += toNumber(item.laengeGeplantM, 0); summary[key].anzahl += 1; }); const rows = Object.keys(summary).map(function(key) { return summary[key]; }); if (!rows.length) { return '
Keine Materialdaten vorhanden.
'; } return [ '
', '', '', '', rows.map(function(row) { return ''; }).join(''), '', '
KategorieTypAnzahlMeter
' + escapeHtml(row.kategorie) + '' + escapeHtml(row.kabeltyp) + '' + escapeHtml(String(row.anzahl)) + '' + escapeHtml(formatLength(row.meter)) + '
', '
' ].join(''); } function renderSidePanel() { if (!state.ui.sidePanelOpen) { return ''; } return [ '
', '' ].join(''); } function openPanel(title, type, mode, entityId, contentHtml) { state.ui.sidePanelOpen = true; state.ui.sidePanelTitle = title; state.ui.sidePanelType = type; state.ui.sidePanelMode = mode || 'create'; state.ui.sidePanelEntityId = entityId || ''; state.ui.sidePanelContent = contentHtml || ''; renderApp(); } function closePanel() { state.ui.sidePanelOpen = false; state.ui.sidePanelTitle = ''; state.ui.sidePanelType = ''; state.ui.sidePanelMode = 'create'; state.ui.sidePanelEntityId = ''; state.ui.sidePanelContent = ''; renderApp(); } function renderLoading() { return '
Daten werden geladen …
'; } function renderActiveTab() { if (state.loading) { return renderLoading(); } switch (state.ui.activeTab) { case TAB_KEYS.TRASSEN: return renderTrassen(); case TAB_KEYS.KABEL: return renderKabel(); case TAB_KEYS.BELEGUNG: return renderBelegung(); case TAB_KEYS.VERLEGUNG: return renderVerlegung(); case TAB_KEYS.PRUEFUNG: return renderPruefung(); case TAB_KEYS.BERICHTE: return renderReports(); case TAB_KEYS.DASHBOARD: default: return renderDashboard(); } } function renderApp() { if (!state.root) { return; } state.ui.renderCount += 1; clearCharts(); state.root.innerHTML = [ '
', renderHeader(), renderAlerts(), renderFilterBar(), renderTabs(), renderActiveTab(), renderSidePanel(), '
' ].join(''); renderCharts(); } function renderChartOrPlaceholder(canvasId, config, placeholderText) { const canvas = state.root.querySelector('#' + canvasId); if (!canvas) { return; } if (typeof window.Chart !== 'function') { const placeholder = document.createElement('div'); placeholder.className = 'bgz-km-chart-placeholder'; placeholder.textContent = placeholderText || 'Chart.js ist nicht geladen.'; canvas.replaceWith(placeholder); return; } const context = canvas.getContext('2d'); state.charts[canvasId] = new window.Chart(context, config); } function renderCharts() { if (!state.root || state.ui.activeTab !== TAB_KEYS.DASHBOARD) { return; } const byType = groupBy(state.kabel, function(item) { return getKabelTypLabel(item.kategorie, item.kabeltyp); }); const typeLabels = Object.keys(byType); const typeValues = typeLabels.map(function(key) { return round(sumBy(byType[key], function(item) { return item.laengeGeplantM; }), 1); }); const trasseItems = sortBy(state.trassen.map(function(item) { return { label: item.bezeichnung, rate: round(getTrasseFillRate(item.id) * 100, 1) }; }), function(item) { return item.rate; }, 'desc').slice(0, 8); const progressPoints = sortBy(state.kabel.map(function(item) { return { date: item.zugtermin || todayISO(), planned: item.laengeGeplantM || 0, installed: (item.status === 'verlegt' || item.status === 'geprueft' || item.status === 'freigegeben') ? toNumber(item.laengeGemessenM || item.laengeGeplantM, 0) : 0 }; }), function(item) { return item.date; }, 'asc'); const groupedProgress = groupBy(progressPoints, function(item) { return item.date; }); const progressLabels = Object.keys(groupedProgress).sort(); const plannedSeries = []; const installedSeries = []; let plannedRunning = 0; let installedRunning = 0; progressLabels.forEach(function(label) { plannedRunning += sumBy(groupedProgress[label], function(item) { return item.planned; }); installedRunning += sumBy(groupedProgress[label], function(item) { return item.installed; }); plannedSeries.push(round(plannedRunning, 1)); installedSeries.push(round(installedRunning, 1)); }); renderChartOrPlaceholder('bgz-km-chart-kabeltypen', { type: 'doughnut', data: { labels: typeLabels, datasets: [ { label: 'Kabelmeter', data: typeValues } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } } }, 'Chart.js nicht verfügbar: Kabeltypenverteilung.'); renderChartOrPlaceholder('bgz-km-chart-trassen', { type: 'bar', data: { labels: trasseItems.map(function(item) { return item.label; }), datasets: [ { label: 'Füllgrad [%]', data: trasseItems.map(function(item) { return item.rate; }) } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }, 'Chart.js nicht verfügbar: Trassenauslastung.'); renderChartOrPlaceholder('bgz-km-chart-fortschritt', { type: 'line', data: { labels: progressLabels, datasets: [ { label: 'Geplant [m]', data: plannedSeries, tension: 0.25 }, { label: 'Verlegt [m]', data: installedSeries, tension: 0.25 } ] }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } } }, 'Chart.js nicht verfügbar: Fortschrittsverlauf.'); } function extractSectionsFromForm(form) { const ids = Array.from(form.querySelectorAll('input[name="section_id[]"]')).map(function(field) { return field.value; }); const labels = Array.from(form.querySelectorAll('input[name="section_bezeichnung[]"]')).map(function(field) { return field.value; }); const starts = Array.from(form.querySelectorAll('input[name="section_startpunkt[]"]')).map(function(field) { return field.value; }); const ends = Array.from(form.querySelectorAll('input[name="section_endpunkt[]"]')).map(function(field) { return field.value; }); const lengths = Array.from(form.querySelectorAll('input[name="section_laengeM[]"]')).map(function(field) { return field.value; }); const corridors = Array.from(form.querySelectorAll('input[name="section_corridor[]"]')).map(function(field) { return field.value; }); const startXs = Array.from(form.querySelectorAll('input[name="section_startX[]"]')).map(function(field) { return field.value; }); const startYs = Array.from(form.querySelectorAll('input[name="section_startY[]"]')).map(function(field) { return field.value; }); const endXs = Array.from(form.querySelectorAll('input[name="section_endX[]"]')).map(function(field) { return field.value; }); const endYs = Array.from(form.querySelectorAll('input[name="section_endY[]"]')).map(function(field) { return field.value; }); const result = []; for (let i = 0; i < labels.length; i += 1) { const section = { id: ids[i] || createUid('section'), bezeichnung: labels[i] || '', startpunkt: starts[i] || '', endpunkt: ends[i] || '', laengeM: toNumber(lengths[i], 0), corridor: corridors[i] || '', startX: startXs[i] !== '' ? toNumber(startXs[i], 0) : '', startY: startYs[i] !== '' ? toNumber(startYs[i], 0) : '', endX: endXs[i] !== '' ? toNumber(endXs[i], 0) : '', endY: endYs[i] !== '' ? toNumber(endYs[i], 0) : '', verknuepftMit: [] }; if (section.bezeichnung || section.startpunkt || section.endpunkt || section.laengeM) { result.push(section); } } return result; } function parseTrasseForm(form) { return { id: readInputValue(form, 'id') || createUid('trasse'), bezeichnung: readInputValue(form, 'bezeichnung'), typ: readInputValue(form, 'typ'), gebaeude: readInputValue(form, 'gebaeude'), geschoss: readInputValue(form, 'geschoss'), nutzbreiteMm: toNumber(readInputValue(form, 'nutzbreiteMm'), 0), nutzhoeheMm: toNumber(readInputValue(form, 'nutzhoeheMm'), 0), tragfaehigkeitKgM: toNumber(readInputValue(form, 'tragfaehigkeitKgM'), 0), material: readInputValue(form, 'material'), brandschutzklasse: readInputValue(form, 'brandschutzklasse'), trennstegVorhanden: !!readInputValue(form, 'trennstegVorhanden'), status: readInputValue(form, 'status'), notizen: readInputValue(form, 'notizen'), abschnitte: extractSectionsFromForm(form) }; } function parseKabelForm(form) { const cable = { id: readInputValue(form, 'id') || createUid('kabel'), nummer: readInputValue(form, 'nummer'), kategorie: readInputValue(form, 'kategorie'), kabeltyp: readInputValue(form, 'kabeltyp'), querschnittMm2: toNumber(readInputValue(form, 'querschnittMm2'), 0), adern: toNumber(readInputValue(form, 'adern'), 0), laengeGeplantM: toNumber(readInputValue(form, 'laengeGeplantM'), 0), laengeGemessenM: toNumber(readInputValue(form, 'laengeGemessenM'), 0), hersteller: readInputValue(form, 'hersteller'), farbe: readInputValue(form, 'farbe'), startpunkt: readInputValue(form, 'startpunkt'), endpunkt: readInputValue(form, 'endpunkt'), gebaeude: readInputValue(form, 'gebaeude'), geschoss: readInputValue(form, 'geschoss'), reserve: !!readInputValue(form, 'reserve'), ersatz: !!readInputValue(form, 'ersatz'), status: readInputValue(form, 'status'), pruefstatus: readInputValue(form, 'pruefstatus'), zugabeStartM: toNumber(readInputValue(form, 'zugabeStartM'), getCableDefaultAllowance({ kategorie: readInputValue(form, 'kategorie') })), zugabeEndM: toNumber(readInputValue(form, 'zugabeEndM'), getCableDefaultAllowance({ kategorie: readInputValue(form, 'kategorie') })), zugtermin: readInputValue(form, 'zugtermin'), notizen: readInputValue(form, 'notizen') }; cable.minBiegeradiusMm = getCableMinBendingRadiusMm(cable); cable.maxZugkraftN = getCableMaxPullForceN(cable); return cable; } function parseVerlegungForm(form) { return { id: readInputValue(form, 'id') || createUid('ver'), kabelId: readInputValue(form, 'kabelId'), datum: readInputValue(form, 'datum'), team: readInputValue(form, 'team'), wetter: readInputValue(form, 'wetter'), protokolltyp: readInputValue(form, 'protokolltyp'), fotos: readTextAreaLines(form, 'fotos'), zugkraftN: toNumber(readInputValue(form, 'zugkraftN'), 0), temperaturC: toNumber(readInputValue(form, 'temperaturC'), 0), zugrichtung: readInputValue(form, 'zugrichtung'), biegeradiusMm: toNumber(readInputValue(form, 'biegeradiusMm'), 0), isolationMOhm: toNumber(readInputValue(form, 'isolationMOhm'), 0), durchgangOk: !!readInputValue(form, 'durchgangOk'), mantelpruefungOk: !!readInputValue(form, 'mantelpruefungOk'), sichtpruefungOk: !!readInputValue(form, 'sichtpruefungOk'), anschlussBestaetigungA: !!readInputValue(form, 'anschlussBestaetigungA'), anschlussBestaetigungB: !!readInputValue(form, 'anschlussBestaetigungB'), notizen: readInputValue(form, 'notizen') }; } function parseMaengelLines(lines) { return ensureArray(lines).map(function(line) { const parts = String(line).split('|').map(function(item) { return item.trim(); }); return { id: createUid('ma'), beschreibung: parts[0] || '', frist: parts[1] || '', behoben: false }; }).filter(function(item) { return !!item.beschreibung; }); } function parsePruefungForm(form) { return { id: readInputValue(form, 'id') || createUid('prf'), kabelId: readInputValue(form, 'kabelId'), planDatum: readInputValue(form, 'planDatum'), pruefDatum: readInputValue(form, 'pruefDatum'), pruefart: readInputValue(form, 'pruefart'), status: readInputValue(form, 'status'), messwerte: { isolationMOhm: toNumber(readInputValue(form, 'isolationMOhm'), 0), schleifenimpedanzOhm: toNumber(readInputValue(form, 'schleifenimpedanzOhm'), 0), rcdMs: toNumber(readInputValue(form, 'rcdMs'), 0), daempfungDb: toNumber(readInputValue(form, 'daempfungDb'), 0), otdr: readInputValue(form, 'otdr'), fluke: readInputValue(form, 'fluke'), catZertifizierung: readInputValue(form, 'catZertifizierung') }, maengel: parseMaengelLines(readTextAreaLines(form, 'maengel')), nachprueftermin: readInputValue(form, 'nachprueftermin'), freigegebenVon: readInputValue(form, 'freigegebenVon'), notizen: readInputValue(form, 'notizen') }; } function upsertEntity(collectionName, entity) { const collection = state[collectionName]; const index = collection.findIndex(function(item) { return String(item.id) === String(entity.id); }); if (index === -1) { collection.push(entity); } else { collection[index] = entity; } } function deleteEntity(collectionName, id) { state[collectionName] = state[collectionName].filter(function(item) { return String(item.id) !== String(id); }); } async function persistTrasse(entity) { const exists = !!getTrasseById(entity.id); const endpoint = exists ? 'trassen/' + encodeURIComponent(entity.id) : 'trassen'; const method = exists ? 'PUT' : 'POST'; try { await apiCall(endpoint, method, entity); return true; } catch (error) { return false; } } async function persistKabel(entity) { const exists = !!getKabelById(entity.id); const endpoint = exists ? 'kabel/' + encodeURIComponent(entity.id) : 'kabel'; const method = exists ? 'PUT' : 'POST'; try { await apiCall(endpoint, method, entity); return true; } catch (error) { return false; } } async function persistVerlegung(entity) { const exists = !!getVerlegungById(entity.id); const endpoint = exists ? 'verlegungen/' + encodeURIComponent(entity.id) : 'verlegungen'; const method = exists ? 'PUT' : 'POST'; try { await apiCall(endpoint, method, entity); return true; } catch (error) { return false; } } async function persistPruefung(entity) { const exists = !!getPruefungById(entity.id); const endpoint = exists ? 'pruefungen/' + encodeURIComponent(entity.id) : 'pruefungen'; const method = exists ? 'PUT' : 'POST'; try { await apiCall(endpoint, method, entity); return true; } catch (error) { return false; } } async function removeViaApi(endpoint) { try { await apiCall(endpoint, 'DELETE'); return true; } catch (error) { return false; } } async function handleTrasseSubmit(form) { const entity = parseTrasseForm(form); if (!entity.bezeichnung || !entity.typ) { notify('warning', 'Bezeichnung und Trassentyp sind Pflichtfelder.'); return; } upsertEntity('trassen', entity); await persistTrasse(entity); refreshDerivedData(); state.ui.selectedTrasseId = entity.id; closePanel(); notify('success', 'Trasse gespeichert.'); } async function handleKabelSubmit(form) { const entity = parseKabelForm(form); if (!entity.nummer || !entity.startpunkt || !entity.endpunkt) { notify('warning', 'Kabelnummer, Startpunkt und Endpunkt sind Pflichtfelder.'); return; } const existing = getKabelById(entity.id); if (existing && existing.route) { entity.route = existing.route; } upsertEntity('kabel', entity); await persistKabel(entity); refreshDerivedData(); state.ui.selectedKabelId = entity.id; closePanel(); notify('success', 'Kabel gespeichert.'); } async function handleVerlegungSubmit(form) { const entity = parseVerlegungForm(form); if (!entity.kabelId) { notify('warning', 'Bitte ein Kabel auswählen.'); return; } upsertEntity('verlegungen', entity); await persistVerlegung(entity); refreshDerivedData(); state.ui.selectedVerlegungId = entity.id; closePanel(); notify('success', 'Verlegeprotokoll gespeichert.'); } async function handlePruefungSubmit(form) { const entity = parsePruefungForm(form); if (!entity.kabelId || !entity.pruefart) { notify('warning', 'Kabel und Prüfart sind Pflichtfelder.'); return; } upsertEntity('pruefungen', entity); await persistPruefung(entity); refreshDerivedData(); state.ui.selectedPruefungId = entity.id; closePanel(); notify('success', 'Prüfung gespeichert.'); } function openCreateTrassePanel() { openPanel('Neue Trasse', 'trasse', 'create', '', buildTrasseFormHtml(null)); } function openEditTrassePanel(id) { const item = getTrasseById(id); if (!item) { notify('warning', 'Trasse nicht gefunden.'); return; } openPanel('Trasse bearbeiten', 'trasse', 'edit', id, buildTrasseFormHtml(item)); } function openCreateKabelPanel() { openPanel('Neues Kabel', 'kabel', 'create', '', buildKabelFormHtml(null)); } function openEditKabelPanel(id) { const item = getKabelById(id); if (!item) { notify('warning', 'Kabel nicht gefunden.'); return; } openPanel('Kabel bearbeiten', 'kabel', 'edit', id, buildKabelFormHtml(item)); } function openCreateVerlegungPanel() { openPanel('Neues Verlegeprotokoll', 'verlegung', 'create', '', buildVerlegungFormHtml(null)); } function openEditVerlegungPanel(id) { const item = getVerlegungById(id); if (!item) { notify('warning', 'Verlegeprotokoll nicht gefunden.'); return; } openPanel('Verlegeprotokoll bearbeiten', 'verlegung', 'edit', id, buildVerlegungFormHtml(item)); } function openCreatePruefungPanel() { openPanel('Neue Prüfung', 'pruefung', 'create', '', buildPruefungFormHtml(null)); } function openEditPruefungPanel(id) { const item = getPruefungById(id); if (!item) { notify('warning', 'Prüfung nicht gefunden.'); return; } openPanel('Prüfung bearbeiten', 'pruefung', 'edit', id, buildPruefungFormHtml(item)); } async function deleteTrasseById(id) { const trasse = getTrasseById(id); if (!trasse) { return; } const occupied = getBelegungenForTrasse(id).length; if (occupied && !window.confirm('Die Trasse hat Belegungen. Wirklich löschen?')) { return; } deleteEntity('trassen', id); state.belegungen = state.belegungen.filter(function(item) { return String(item.trasseId) !== String(id); }); await removeViaApi('trassen/' + encodeURIComponent(id)); refreshDerivedData(); state.ui.selectedTrasseId = ''; renderApp(); notify('success', 'Trasse gelöscht.'); } async function deleteKabelById(id) { const cable = getKabelById(id); if (!cable) { return; } if (!window.confirm('Kabel ' + cable.nummer + ' wirklich löschen?')) { return; } deleteEntity('kabel', id); state.belegungen = state.belegungen.filter(function(item) { return String(item.kabelId) !== String(id); }); state.pruefungen = state.pruefungen.filter(function(item) { return String(item.kabelId) !== String(id); }); state.verlegungen = state.verlegungen.filter(function(item) { return String(item.kabelId) !== String(id); }); await removeViaApi('kabel/' + encodeURIComponent(id)); refreshDerivedData(); state.ui.selectedKabelId = ''; renderApp(); notify('success', 'Kabel gelöscht.'); } function createManualRouteForTrasse(kabelId, trasseId) { const cable = getKabelById(kabelId); const trasse = getTrasseById(trasseId); if (!cable || !trasse) { return { ok: false, message: 'Kabel oder Trasse nicht gefunden.' }; } const firstSection = ensureArray(trasse.abschnitte)[0]; if (!firstSection) { return { ok: false, message: 'Trasse hat keine Abschnitte.' }; } const route = { nodes: [normalizeNodeKey(firstSection.startpunkt), normalizeNodeKey(firstSection.endpunkt)], edges: [ { from: normalizeNodeKey(firstSection.startpunkt), to: normalizeNodeKey(firstSection.endpunkt), length: estimateSectionLength(firstSection), sectionId: firstSection.id, trasseId: trasse.id, trasseBezeichnung: trasse.bezeichnung, sectionBezeichnung: firstSection.bezeichnung, corridor: firstSection.corridor } ], lengthM: estimateSectionLength(firstSection) }; const compliance = evaluateRouteCompliance(route, cable); removeBelegungenForCable(kabelId); createBelegungenForRoute(kabelId, route, true); updateCableRouteData(cable, route, compliance); refreshDerivedData(); renderApp(); return { ok: true, compliance: compliance }; } async function removeAssignmentById(id) { const assignment = findById(state.belegungen, id); if (!assignment) { return; } state.belegungen = state.belegungen.filter(function(item) { return String(item.id) !== String(id); }); const remaining = getBelegungenForKabel(assignment.kabelId); const cable = getKabelById(assignment.kabelId); if (cable && !remaining.length) { cable.route = null; cable.laengeGeplantM = 0; } await removeViaApi('belegungen/' + encodeURIComponent(id)); refreshDerivedData(); renderApp(); notify('success', 'Belegung entfernt.'); } function exportCableListCsv() { const rows = [ ['Kabelnummer', 'Kategorie', 'Typ', 'Startpunkt', 'Endpunkt', 'Gebäude', 'Geschoss', 'Länge geplant [m]', 'Länge gemessen [m]', 'Status', 'Prüfstatus', 'Reserve', 'Ersatz'] ]; getFilteredKabel().forEach(function(item) { rows.push([ item.nummer, getKategorieMeta(item.kategorie).label, getKabelTypLabel(item.kategorie, item.kabeltyp), item.startpunkt, item.endpunkt, item.gebaeude, item.geschoss, item.laengeGeplantM, item.laengeGemessenM, getStatusMeta(item.status).label, getStatusMeta(item.pruefstatus).label, item.reserve ? 'Ja' : 'Nein', item.ersatz ? 'Ja' : 'Nein' ]); }); downloadFile('kabelliste.csv', 'text/csv;charset=utf-8', toCsv(rows)); } function exportOccupancyCsv() { const rows = [['Kabel', 'Trasse', 'Abschnitt', 'Meter', 'Warnungen']]; state.belegungen.forEach(function(item) { const cable = getKabelById(item.kabelId); const trasse = getTrasseById(item.trasseId); const section = getSectionById(item.sectionId); rows.push([ cable ? cable.nummer : item.kabelId, trasse ? trasse.bezeichnung : item.trasseId, section ? section.bezeichnung : item.sectionId, item.meter, ensureArray(item.warnings).join(', ') ]); }); downloadFile('trassenbelegung.csv', 'text/csv;charset=utf-8', toCsv(rows)); } function exportTestsCsv() { const rows = [['Kabel', 'Prüfart', 'Plan-Datum', 'Prüfdatum', 'Status', 'Isolationswert', 'Schleifenimpedanz', 'RCD', 'Dämpfung', 'Freigegeben von', 'Nachprüfung']]; state.pruefungen.forEach(function(item) { const cable = getKabelById(item.kabelId); rows.push([ cable ? cable.nummer : item.kabelId, getPruefartLabel(item.pruefart), item.planDatum, item.pruefDatum, getStatusMeta(item.status).label, item.messwerte && item.messwerte.isolationMOhm, item.messwerte && item.messwerte.schleifenimpedanzOhm, item.messwerte && item.messwerte.rcdMs, item.messwerte && item.messwerte.daempfungDb, item.freigegebenVon, item.nachprueftermin ]); }); downloadFile('pruefungen.csv', 'text/csv;charset=utf-8', toCsv(rows)); } function exportMaterialCsv() { const rows = [['Kategorie', 'Typ', 'Anzahl', 'Geplante Meter']]; const summary = {}; state.kabel.forEach(function(item) { const key = item.kategorie + '|' + item.kabeltyp; if (!summary[key]) { summary[key] = { kategorie: getKategorieMeta(item.kategorie).label, typ: getKabelTypLabel(item.kategorie, item.kabeltyp), anzahl: 0, meter: 0 }; } summary[key].anzahl += 1; summary[key].meter += toNumber(item.laengeGeplantM, 0); }); Object.keys(summary).forEach(function(key) { rows.push([summary[key].kategorie, summary[key].typ, summary[key].anzahl, round(summary[key].meter, 1)]); }); downloadFile('materialauszug.csv', 'text/csv;charset=utf-8', toCsv(rows)); } function exportCableListPdf() { const html = [ '

Kabelliste

', '
Erstellt am ' + escapeHtml(formatDateTime(nowISO())) + '
', '', getFilteredKabel().map(function(item) { return ''; }).join(''), '
KabelnummerKategorieTypFührungLänge geplantStatus
' + escapeHtml(item.nummer) + '' + escapeHtml(getKategorieMeta(item.kategorie).label) + '' + escapeHtml(getKabelTypLabel(item.kategorie, item.kabeltyp)) + '' + escapeHtml(item.startpunkt + ' → ' + item.endpunkt) + '' + escapeHtml(formatLength(item.laengeGeplantM || 0)) + '' + escapeHtml(getStatusMeta(item.status).label) + '
' ].join(''); openPrintWindow('Kabelliste', html); } function exportOccupancyPdf() { const html = [ '

Trassenbelegungsplan

', '
Erstellt am ' + escapeHtml(formatDateTime(nowISO())) + '
', '', getFilteredTrassen().map(function(item) { const assignments = getBelegungenForTrasse(item.id); const warnings = unique(assignments.reduce(function(out, entry) { return out.concat(ensureArray(entry.warnings)); }, [])); return ''; }).join(''), '
TrasseFüllgradMaxKabelanzahlHinweise
' + escapeHtml(item.bezeichnung) + '' + escapeHtml(formatPercent(getTrasseFillRate(item.id), 1)) + '' + escapeHtml(formatPercent(getTrasseMaxFillRate(item), 0)) + '' + escapeHtml(String(assignments.length)) + '' + escapeHtml(warnings.join(', ') || '—') + '
' ].join(''); openPrintWindow('Trassenbelegungsplan', html); } function exportTestsPdf() { const html = [ '

Prüfprotokolle

', '
Erstellt am ' + escapeHtml(formatDateTime(nowISO())) + '
', '', state.pruefungen.map(function(item) { const cable = getKabelById(item.kabelId); return ''; }).join(''), '
KabelPrüfartPlanPrüfdatumStatusMängel
' + escapeHtml(cable ? cable.nummer : item.kabelId) + '' + escapeHtml(getPruefartLabel(item.pruefart)) + '' + escapeHtml(formatDate(item.planDatum)) + '' + escapeHtml(formatDate(item.pruefDatum)) + '' + escapeHtml(getStatusMeta(item.status).label) + '' + escapeHtml(String(ensureArray(item.maengel).length)) + '
' ].join(''); openPrintWindow('Prüfprotokolle', html); } function exportMaterialPdf() { const html = [ '

Materialauszug

', '
Erstellt am ' + escapeHtml(formatDateTime(nowISO())) + '
', '
' + renderMaterialSummaryTable() + '
' ].join(''); openPrintWindow('Materialauszug', html); } function exportAsbuiltPdf() { const html = [ '

Bestandsdokumentation

', '
Erstellt am ' + escapeHtml(formatDateTime(nowISO())) + '
', '

Kabel

', '', state.kabel.map(function(item) { return ''; }).join(''), '
NummerFührungRouteStatus
' + escapeHtml(item.nummer) + '' + escapeHtml(item.startpunkt + ' → ' + item.endpunkt) + '' + escapeHtml(item.route ? ensureArray(item.route.trassen).join(' → ') : '—') + '' + escapeHtml(getStatusMeta(item.status).label) + '
', '

Trassen

', '', state.trassen.map(function(item) { return ''; }).join(''), '
TrasseTypGebäude/GeschossFüllgrad
' + escapeHtml(item.bezeichnung) + '' + escapeHtml(getTrasseTypMeta(item.typ).label) + '' + escapeHtml(item.gebaeude + ' / ' + item.geschoss) + '' + escapeHtml(formatPercent(getTrasseFillRate(item.id), 1)) + '
' ].join(''); openPrintWindow('Bestandsdokumentation', html); } function exportAsbuiltCsv() { const rows = [['Typ', 'Bezeichnung', 'Detail 1', 'Detail 2', 'Status']]; state.kabel.forEach(function(item) { rows.push(['Kabel', item.nummer, item.startpunkt + ' → ' + item.endpunkt, item.route ? ensureArray(item.route.trassen).join(' → ') : '', getStatusMeta(item.status).label]); }); state.trassen.forEach(function(item) { rows.push(['Trasse', item.bezeichnung, getTrasseTypMeta(item.typ).label, item.gebaeude + ' / ' + item.geschoss, getStatusMeta(item.status).label]); }); downloadFile('bestandsdokumentation.csv', 'text/csv;charset=utf-8', toCsv(rows)); } function setFilter(key, value) { state.filters[key] = value; if (key === 'suche' && typeof value === 'string') { state.filters[key] = value; } renderApp(); } function resetFilters() { state.filters = Object.assign({}, DEFAULT_FILTERS); renderApp(); } function updateKabelTypeSelectForCategory(selectElement, category) { if (!selectElement) { return; } selectElement.innerHTML = createOptionList(KABEL_TYPEN[category] || [], 'value', 'label', '', false); } function onRootClick(event) { const trigger = event.target.closest('[data-action]'); if (!trigger) { return; } const action = trigger.getAttribute('data-action'); const id = trigger.getAttribute('data-id'); const tab = trigger.getAttribute('data-tab'); if (action === 'dismiss-alert') { dismissAlert(id); return; } if (action === 'set-tab') { state.ui.activeTab = tab; renderApp(); return; } if (action === 'reset-filters') { resetFilters(); return; } if (action === 'refresh-data') { loadInitialData(); return; } if (action === 'load-demo') { mergeDataset(buildDemoData()); state.demoMode = true; refreshDerivedData(); renderApp(); notify('info', 'Demo-Daten neu geladen.'); return; } if (action === 'close-panel') { closePanel(); return; } if (action === 'create-trasse') { openCreateTrassePanel(); return; } if (action === 'edit-trasse') { openEditTrassePanel(id); return; } if (action === 'delete-trasse') { deleteTrasseById(id); return; } if (action === 'select-trasse') { state.ui.selectedTrasseId = id; renderApp(); return; } if (action === 'create-kabel') { openCreateKabelPanel(); return; } if (action === 'edit-kabel') { openEditKabelPanel(id); return; } if (action === 'delete-kabel') { deleteKabelById(id); return; } if (action === 'select-kabel') { state.ui.selectedKabelId = id; renderApp(); return; } if (action === 'auto-route-cable') { const result = autoPlanCableRoute(id); refreshDerivedData(); renderApp(); notify(result.ok ? 'success' : 'warning', result.message); return; } if (action === 'auto-route-all') { const results = autoPlanAllUnroutedCables(); const okCount = results.filter(function(item) { return item.ok; }).length; const failedCount = results.length - okCount; notify('info', 'Auto-Route abgeschlossen. Erfolgreich: ' + okCount + ', offen: ' + failedCount + '.'); return; } if (action === 'create-verlegung') { openCreateVerlegungPanel(); return; } if (action === 'edit-verlegung') { openEditVerlegungPanel(id); return; } if (action === 'select-verlegung') { state.ui.selectedVerlegungId = id; renderApp(); return; } if (action === 'create-pruefung') { openCreatePruefungPanel(); return; } if (action === 'edit-pruefung') { openEditPruefungPanel(id); return; } if (action === 'select-pruefung') { state.ui.selectedPruefungId = id; renderApp(); return; } if (action === 'release-pruefung') { const test = getPruefungById(id); if (test) { test.status = 'freigegeben'; if (!test.pruefDatum) { test.pruefDatum = todayISO(); } if (!test.freigegebenVon) { test.freigegebenVon = 'BauGenio Freigabe'; } persistPruefung(test); refreshDerivedData(); renderApp(); notify('success', 'Prüfung freigegeben.'); } return; } if (action === 'remove-assignment') { removeAssignmentById(id); return; } if (action === 'add-section-row') { const tbody = state.root.querySelector('[data-role="section-rows"]'); if (tbody) { const wrapper = document.createElement('tbody'); wrapper.innerHTML = renderSectionRowsForTrasseForm([]); const row = wrapper.firstElementChild; if (row) { tbody.appendChild(row); } } return; } if (action === 'remove-section-row') { const row = trigger.closest('tr'); if (row && row.parentNode && row.parentNode.children.length > 1) { row.parentNode.removeChild(row); } return; } if (action === 'export-cable-list-csv') { exportCableListCsv(); return; } if (action === 'export-cable-list-pdf') { exportCableListPdf(); return; } if (action === 'export-occupancy-csv') { exportOccupancyCsv(); return; } if (action === 'export-occupancy-pdf') { exportOccupancyPdf(); return; } if (action === 'export-test-csv') { exportTestsCsv(); return; } if (action === 'export-test-pdf') { exportTestsPdf(); return; } if (action === 'export-material-csv') { exportMaterialCsv(); return; } if (action === 'export-material-pdf') { exportMaterialPdf(); return; } if (action === 'export-asbuilt-pdf') { exportAsbuiltPdf(); return; } if (action === 'export-asbuilt-csv') { exportAsbuiltCsv(); return; } } function onRootChange(event) { const filterKey = event.target.getAttribute('data-filter'); if (filterKey) { const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; setFilter(filterKey, value); return; } if (event.target.matches('[data-role="kabel-kategorie-select"]')) { const category = event.target.value; const form = event.target.closest('form'); const typeSelect = form ? form.querySelector('[data-role="kabel-typ-select"]') : null; updateKabelTypeSelectForCategory(typeSelect, category); } } function onRootInput(event) { const filterKey = event.target.getAttribute('data-filter'); if (filterKey === 'suche') { setFilter(filterKey, event.target.value); } } function onRootSubmit(event) { const form = event.target; if (!form || !form.matches('form[data-form]')) { return; } event.preventDefault(); const formKey = form.getAttribute('data-form'); if (formKey === 'trasse-form') { handleTrasseSubmit(form); return; } if (formKey === 'kabel-form') { handleKabelSubmit(form); return; } if (formKey === 'verlegung-form') { handleVerlegungSubmit(form); return; } if (formKey === 'pruefung-form') { handlePruefungSubmit(form); } } function onRootDragStart(event) { const card = event.target.closest('[data-role="draggable-cable"]'); if (!card) { return; } state.ui.dragCableId = card.getAttribute('data-id') || ''; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', state.ui.dragCableId); } function onRootDragOver(event) { const zone = event.target.closest('[data-role="trasse-dropzone"]'); if (!zone) { return; } event.preventDefault(); zone.classList.add('is-over'); } function onRootDragLeave(event) { const zone = event.target.closest('[data-role="trasse-dropzone"]'); if (zone) { zone.classList.remove('is-over'); } } function onRootDrop(event) { const zone = event.target.closest('[data-role="trasse-dropzone"]'); if (!zone) { return; } event.preventDefault(); zone.classList.remove('is-over'); const kabelId = event.dataTransfer.getData('text/plain') || state.ui.dragCableId; const trasseId = zone.getAttribute('data-trasse-id'); if (!kabelId || !trasseId) { return; } const result = createManualRouteForTrasse(kabelId, trasseId); notify(result.ok ? 'success' : 'warning', result.ok ? 'Kabel auf Trasse belegt.' : result.message); state.ui.dragCableId = ''; } function bindEvents() { if (!state.root || state.listenersBound) { return; } state.root.addEventListener('click', onRootClick); state.root.addEventListener('change', onRootChange); state.root.addEventListener('input', onRootInput); state.root.addEventListener('submit', onRootSubmit); state.root.addEventListener('dragstart', onRootDragStart); state.root.addEventListener('dragover', onRootDragOver); state.root.addEventListener('dragleave', onRootDragLeave); state.root.addEventListener('drop', onRootDrop); state.listenersBound = true; } function unbindEvents() { if (!state.root || !state.listenersBound) { return; } state.root.removeEventListener('click', onRootClick); state.root.removeEventListener('change', onRootChange); state.root.removeEventListener('input', onRootInput); state.root.removeEventListener('submit', onRootSubmit); state.root.removeEventListener('dragstart', onRootDragStart); state.root.removeEventListener('dragover', onRootDragOver); state.root.removeEventListener('dragleave', onRootDragLeave); state.root.removeEventListener('drop', onRootDrop); state.listenersBound = false; } async function init(container) { injectStyles(); const target = typeof container === 'string' ? document.querySelector(container) : container; if (!target) { throw new Error('BauGenioKabelmanagement: Container nicht gefunden.'); } resetState(); state.container = target; state.root = document.createElement('div'); state.root.id = MODULE_ID; state.container.innerHTML = ''; state.container.appendChild(state.root); bindEvents(); state.initialized = true; state.destroyed = false; renderApp(); await loadInitialData(); } function destroy() { clearCharts(); unbindEvents(); removeStyles(); if (state.root && state.root.parentNode) { state.root.parentNode.removeChild(state.root); } state.container = null; state.root = null; state.initialized = false; state.destroyed = true; resetState(); } async function refresh() { if (!state.initialized || !state.root) { return; } await loadInitialData(); } window.BauGenioKabelmanagement = { init: init, destroy: destroy, refresh: refresh }; })(); async function handleKabelSubmit(form) { const entity = parseKabelForm(form); if (!entity.nummer || !entity.startpunkt || !entity.endpunkt) { notify('warning', 'Kabelnummer, Startpunkt und Endpunkt sind Pflichtfelder.'); return; } const existing = getKabelById(entity.id); if (existing && existing.route) { entity.route = existing.route; } upsertEntity('kabel', entity); await persistKabel(entity); refreshDerivedData(); state.ui.selectedKabelId = entity.id; closePanel(); notify('success', 'Kabel gespeichert.'); } async function handleKabelSubmit(form) { const entity = parseKabelForm(form); if (!entity.nummer || !entity.startpunkt || !entity.endpunkt) { notify('warning', 'Kabelnummer, Startpunkt und Endpunkt sind Pflichtfelder.'); return; } const existing = getKabelById(entity.id); if (existing && existing.route) { const routeStillValid = existing.startpunkt === entity.startpunkt && existing.endpunkt === entity.endpunkt; if (routeStillValid) { entity.route = existing.route; } else { removeBelegungenForCable(entity.id); entity.route = null; } } upsertEntity('kabel', entity); await persistKabel(entity); refreshDerivedData(); state.ui.selectedKabelId = entity.id; closePanel(); notify('success', 'Kabel gespeichert.'); } ' ].join(''); } async function tryServerExport(endpoint, payload) { try { const response = await apiCall(endpoint, 'POST', payload); return response && (response.data || response) ? (response.data || response) : null; } catch (error) { return null; } } async function exportPdfReport() { const flaeche = getSelectedFlaeche(); if (!flaeche) { showToast('Keine Fläche für Bericht ausgewählt.', 'warning'); return; } setLoading(true); try { const serverResult = await tryServerExport('exports/pdf', { flaecheId: flaeche.id, typ: 'baustellenbericht' }); if (serverResult && serverResult.url) { window.open(serverResult.url, '_blank', 'noopener'); showToast('PDF-Export vom Server gestartet.', 'success'); return; } const html = buildReportHtml(flaeche); const win = window.open('', '_blank', 'noopener'); if (!win) { throw new Error('Pop-up blockiert.'); } win.document.open(); win.document.write(html); win.document.close(); win.focus(); win.print(); showToast('PDF-Bericht im Druckdialog geöffnet.', 'success'); } catch (error) { showToast('PDF-Export fehlgeschlagen.', 'danger', error.message); } finally { setLoading(false); } } function buildCsvRows() { const rows = [ [ 'Projekt', 'Gebäude', 'Bereich', 'Raumnummer', 'Fläche', 'Bodentyp', 'Status', 'Schicht', 'Material', 'Charge', 'Menge', 'Verbrauch/m²', 'Prüfart', 'Soll', 'Ist', 'Bewertung' ] ]; getFilteredFlaechen().forEach(function(flaeche) { const schichten = getSchichtenByFlaeche(flaeche.id); const pruefungen = getPruefungenByFlaeche(flaeche.id); const rowCount = Math.max(schichten.length, pruefungen.length, 1); for (let index = 0; index < rowCount; index += 1) { const schicht = schichten[index] || {}; const pruefung = pruefungen[index] || {}; rows.push([ flaeche.projektNr, flaeche.gebaeude, flaeche.bereich, flaeche.raumnummer, flaeche.flaecheM2, bodenTypLabel(flaeche.bodentyp), flaeche.status, schicht.typ || '', schicht.materialName || '', schicht.charge || '', schicht.menge || '', schicht.verbrauchProM2 || '', pruefung.art || '', pruefung.sollwert || '', pruefung.istwert || '', pruefung.bewertung || '' ]); } }); return rows; } function rowsToCsv(rows) { return rows.map(function(row) { return row.map(function(cell) { const text = safeString(cell); if (/[;"\\n]/.test(text)) { return '"' + text.replace(/"/g, '""') + '"'; } return text; }).join(';'); }).join('\\n'); } async function exportExcel() { setLoading(true); try { const serverResult = await tryServerExport('exports/excel', { filters: state.filters }); if (serverResult && serverResult.url) { window.open(serverResult.url, '_blank', 'noopener'); showToast('Excel-Export vom Server gestartet.', 'success'); return; } const csv = rowsToCsv(buildCsvRows()); downloadBlob('industrieboden-export.csv', 'text/csv;charset=utf-8', csv); showToast('CSV-Export erzeugt.', 'success'); } catch (error) { showToast('Excel/CSV-Export fehlgeschlagen.', 'danger', error.message); } finally { setLoading(false); } } async function exportQmDossier() { const flaeche = getSelectedFlaeche(); if (!flaeche) { showToast('Keine Fläche ausgewählt.', 'warning'); return; } setLoading(true); try { const serverResult = await tryServerExport('exports/dossier', { flaecheId: flaeche.id, typ: 'qm-dossier' }); if (serverResult && serverResult.url) { window.open(serverResult.url, '_blank', 'noopener'); showToast('QM-Dossier vom Server gestartet.', 'success'); return; } const payload = { flaeche: flaeche, checks: getChecksByFlaeche(flaeche.id), pruefungen: getPruefungenByFlaeche(flaeche.id), freigaben: getFreigabenByFlaeche(flaeche.id), dokumente: getDokumenteByFlaeche(flaeche.id) }; downloadBlob('qm-dossier-' + flaeche.id + '.json', 'application/json;charset=utf-8', JSON.stringify(payload, null, 2)); showToast('QM-Dossier als JSON exportiert.', 'success'); } catch (error) { showToast('QM-Dossier konnte nicht erstellt werden.', 'danger', error.message); } finally { setLoading(false); } } async function exportProtocols() { const flaeche = getSelectedFlaeche(); if (!flaeche) { showToast('Keine Fläche ausgewählt.', 'warning'); return; } setLoading(true); try { const serverResult = await tryServerExport('exports/protocols', { flaecheId: flaeche.id }); if (serverResult && serverResult.url) { window.open(serverResult.url, '_blank', 'noopener'); showToast('Prüfprotokolle vom Server gestartet.', 'success'); return; } const items = getPruefungenByFlaeche(flaeche.id).map(function(pruefung) { return { art: pruefung.art, norm: pruefung.norm, zeitpunkt: pruefung.zeitpunkt, sollwert: pruefung.sollwert, istwert: pruefung.istwert, einheit: pruefung.einheit, bewertung: evaluatePruefung(pruefung, flaeche), pruefer: pruefung.pruefer, bemerkung: pruefung.bemerkung, zusatzdaten: pruefung.zusatzdaten }; }); downloadBlob('pruefprotokolle-' + flaeche.id + '.json', 'application/json;charset=utf-8', JSON.stringify(items, null, 2)); showToast('Prüfprotokolle exportiert.', 'success'); } catch (error) { showToast('Prüfprotokolle konnten nicht exportiert werden.', 'danger', error.message); } finally { setLoading(false); } } function checkAutomaticNotifications() { const notices = []; state.flaechen.forEach(function(flaeche) { const cure = currentCureStatus(flaeche.id); if (cure && cure.progress >= 100 && !state.benachrichtigungen.some(function(item) { return item.type === 'cure-ready' && item.flaecheId === flaeche.id; })) { const notice = { id: uid('notice'), type: 'cure-ready', flaecheId: flaeche.id, createdAt: nowIso(), message: 'Sperrfrist erreicht: ' + flaeche.bezeichnung + ' ist belastbar.' }; state.benachrichtigungen.push(notice); notices.push(notice); } const warnings = computeLoggerWarnings(flaeche.id); if (warnings.length && !state.benachrichtigungen.some(function(item) { return item.type === 'logger-warning' && item.flaecheId === flaeche.id && item.createdAt.slice(0, 10) === todayIso(); })) { const notice = { id: uid('notice'), type: 'logger-warning', flaecheId: flaeche.id, createdAt: nowIso(), message: 'Aushärtungswarnung: ' + flaeche.bezeichnung + ' hat kritische Loggerwerte.' }; state.benachrichtigungen.push(notice); notices.push(notice); } }); notices.forEach(function(item) { showToast(item.message, item.type === 'cure-ready' ? 'success' : 'warning'); }); } function startTimers() { stopTimers(); state.timers.notification = window.setInterval(function() { checkAutomaticNotifications(); recalcAllDerivedFields(); if (state.ui.lastRenderedView === 'dashboard' || state.ui.lastRenderedView === 'trocknung') { renderCurrentView(); } else { renderSidePanel(); } }, state.options.notificationInterval || 60000); } function stopTimers() { Object.keys(state.timers).forEach(function(key) { window.clearInterval(state.timers[key]); window.clearTimeout(state.timers[key]); }); state.timers = {}; } function evaluatePruefung(pruefung, flaeche) { const art = pruefung.art; const ist = pruefung.istwert; const soll = pruefung.sollwert; if (art === 'Haftzugprüfung') { return ist >= Math.max(1.5, soll) ? 'i.O.' : 'Abweichung'; } if (art === 'Nassschichtdicke' || art === 'Trockenschichtdicke') { const diff = Math.abs(ist - soll); return diff <= (soll * 0.1) ? 'i.O.' : 'Abweichung'; } if (art === 'Rutschhemmung') { return safeString(pruefung.zusatzdaten.rWert || '').trim() >= safeString(flaeche ? flaeche.rutschhemmung : '').trim() ? 'i.O.' : 'Abweichung'; } if (art.indexOf('ESD') === 0) { return pruefung.zusatzdaten && pruefung.zusatzdaten.esdWiderstand ? 'i.O.' : 'Abweichung'; } if (art === 'Härteprüfung') { return ist >= soll ? 'i.O.' : 'Abweichung'; } return pruefung.bewertung || 'offen'; } function extractRValue(value) { const match = safeString(value).match(/R\\s*(\\d+)/i); return match ? parseInt(match[1], 10) : NaN; } function evaluatePruefung(pruefung, flaeche) { const art = pruefung.art; const ist = pruefung.istwert; const soll = pruefung.sollwert; if (art === 'Haftzugprüfung') { return ist >= Math.max(1.5, soll) ? 'i.O.' : 'Abweichung'; } if (art === 'Nassschichtdicke' || art === 'Trockenschichtdicke') { const diff = Math.abs(ist - soll); return diff <= (soll * 0.1) ? 'i.O.' : 'Abweichung'; } if (art === 'Rutschhemmung') { const istR = extractRValue(pruefung.zusatzdaten.rWert || pruefung.istwert); const sollR = extractRValue(flaeche ? flaeche.rutschhemmung : pruefung.sollwert); if (Number.isFinite(istR) && Number.isFinite(sollR)) { return istR >= sollR ? 'i.O.' : 'Abweichung'; } return pruefung.bewertung || 'offen'; } if (art.indexOf('ESD') === 0) { return pruefung.zusatzdaten && pruefung.zusatzdaten.esdWiderstand ? 'i.O.' : 'Abweichung'; } if (art === 'Härteprüfung') { return ist >= soll ? 'i.O.' : 'Abweichung'; } return pruefung.bewertung || 'offen'; } async function ensureChartJs() { if (window.Chart) { return window.Chart; } if (state.ui.chartScriptPromise) { return state.ui.chartScriptPromise; } state.ui.chartScriptPromise = new Promise(function(resolve, reject) { const script = document.createElement('script'); script.src = CHART_JS_URL; script.async = true; script.onload = function() { resolve(window.Chart); }; script.onerror = function() { reject(new Error('Chart.js konnte nicht geladen werden.')); }; document.head.appendChild(script); }); return state.ui.chartScriptPromise; } async function ensureChartJs() { if (window.Chart) { return window.Chart; } if (state.options && state.options.autoLoadChartJs === false) { return null; } if (state.ui.chartScriptPromise) { return state.ui.chartScriptPromise; } state.ui.chartScriptPromise = new Promise(function(resolve, reject) { const script = document.createElement('script'); script.src = CHART_JS_URL; script.async = true; script.onload = function() { resolve(window.Chart); }; script.onerror = function() { reject(new Error('Chart.js konnte nicht geladen werden.')); }; document.head.appendChild(script); }); return state.ui.chartScriptPromise; } async function handleFormSubmit(event) { const form = event.target; const action = form.getAttribute('data-action'); const data = new FormData(form); if (action === 'submitFlaeche') { const payload = mapFlaecheForm(data); const saved = await saveEntity('flaeche', payload); const existingLayers = getSchichtenByFlaeche(saved.id); if (!existingLayers.length) { const definition = KATALOGE.bodenTypen.find(function(item) { return item.key === saved.bodentyp; }); const defaults = ensureArray(definition && definition.defaultSchichten); for (let index = 0; index < defaults.length; index += 1) { try { await saveEntity('schicht', normalizeSchicht({ flaecheId: saved.id, reihenfolge: index + 1, typ: defaults[index], status: 'geplant', materialName: defaults[index] })); } catch (error) { // Einzelne Defaults sollen das Speichern der Fläche nicht blockieren } } } } } function buildCsvRows() { const rows = [ [ 'Projekt', 'Gebäude', 'Bereich', 'Raumnummer', 'Fläche', 'Bodentyp', 'Status', 'Schicht', 'Material', 'Charge', 'Menge', 'Verbrauch/m²', 'Prüfart', 'Soll', 'Ist', 'Bewertung' ] ]; getFilteredFlaechen().forEach(function(flaeche) { const schichten = getSchichtenByFlaeche(flaeche.id); const pruefungen = getPruefungenByFlaeche(flaeche.id); const rowCount = Math.max(schichten.length, pruefungen.length, 1); for (let index = 0; index < rowCount; index += 1) { const schicht = schichten[index] || {}; const pruefung = pruefungen[index] || {}; rows.push([ flaeche.projektNr, flaeche.gebaeude, flaeche.bereich, flaeche.raumnummer, flaeche.flaecheM2, bodenTypLabel(flaeche.bodentyp), flaeche.status, schicht.typ || '', schicht.materialName || '', schicht.charge || '', schicht.menge || '', schicht.verbrauchProM2 || '', pruefung.art || '', pruefung.sollwert || '', pruefung.istwert || '', pruefung.bewertung || '' ]); } }); return rows; } function buildCsvRows() { const rows = [ [ 'PROJEKTE / FLÄCHEN' ], [ 'Projekt', 'Gebäude', 'Bereich', 'Raumnummer', 'Fläche', 'Bodentyp', 'Status', 'Schicht', 'Material', 'Charge', 'Menge', 'Verbrauch/m²', 'Prüfart', 'Soll', 'Ist', 'Bewertung' ] ]; getFilteredFlaechen().forEach(function(flaeche) { const schichten = getSchichtenByFlaeche(flaeche.id); const pruefungen = getPruefungenByFlaeche(flaeche.id); const rowCount = Math.max(schichten.length, pruefungen.length, 1); for (let index = 0; index < rowCount; index += 1) { const schicht = schichten[index] || {}; const pruefung = pruefungen[index] || {}; rows.push([ flaeche.projektNr, flaeche.gebaeude, flaeche.bereich, flaeche.raumnummer, flaeche.flaecheM2, bodenTypLabel(flaeche.bodentyp), flaeche.status, schicht.typ || '', schicht.materialName || '', schicht.charge || '', schicht.menge || '', schicht.verbrauchProM2 || '', pruefung.art || '', pruefung.sollwert || '', pruefung.istwert || '', pruefung.bewertung || '' ]); } }); rows.push([]); rows.push(['MESSDATEN / HEATMAP']); rows.push([ 'Projekt', 'Gebäude', 'Raum', 'Check-ID', 'Messpunkt-ID', 'x', 'y', 'Typ', 'Wert', 'Einheit', 'Zone', 'Kommentar' ]); getFilteredFlaechen().forEach(function(flaeche) { const checks = getChecksByFlaeche(flaeche.id); checks.forEach(function(check) { getMesspunkteByCheck(check.id).forEach(function(point) { rows.push([ flaeche.projektNr, flaeche.gebaeude, flaeche.raumnummer, check.id, point.id, point.x, point.y, point.typ, point.wert, point.einheit, point.zone, point.kommentar ]); }); }); }); return rows; } if (start || end) { const relevantDates = [flaeche.startGeplant, flaeche.endeGeplant, flaeche.freigabeGeplant] .map(function(item) { return item ? new Date(item).getTime() : null; }) .filter(function(item) { return item !== null && !Number.isNaN(item); }); if (relevantDates.length) { const within = relevantDates.some(function(value) { if (start !== null && value < start) { return false; } if (end !== null && value > (end + 86399999)) { return false; } return true; }); if (!within) { return false; } } } if (start || end) { const relevantDates = [flaeche.startGeplant, flaeche.endeGeplant, flaeche.freigabeGeplant] .concat(getSchichtenByFlaeche(flaeche.id).map(function(item) { return item.verarbeitungsStart || item.verarbeitungsEnde; })) .concat(getPruefungenByFlaeche(flaeche.id).map(function(item) { return item.zeitpunkt; })) .map(function(item) { return item ? new Date(item).getTime() : null; }) .filter(function(item) { return item !== null && !Number.isNaN(item); }); if (relevantDates.length) { const within = relevantDates.some(function(value) { if (start !== null && value < start) { return false; } if (end !== null && value > (end + 86399999)) { return false; } return true; }); if (!within) { return false; } } } ', '' ].join(''); } function exportRowsAsExcel(filename, rows, sheetName) { downloadText(filename + '.xls', buildExcelHtml(rows, sheetName), 'application/vnd.ms-excel;charset=utf-8'); } function exportRowsAsCsv(filename, rows) { downloadText(filename + '.csv', toCsv(rows), 'text/csv;charset=utf-8'); } function printHtmlAsPdf(title, html) { const printWindow = window.open('', '_blank'); if (!printWindow) { showError('Popup-Blocker verhindert den PDF-/Druckexport.'); return; } printWindow.document.open(); printWindow.document.write([ '', '', '', '', '' + escapeHtml(title) + '', '', ' ', '', html, ' ', '' ].join('')); printWindow.document.close(); } function exportReportPdf(reportType) { const report = generateReport(reportType); if (window.jspdf && window.jspdf.jsPDF) { const doc = new window.jspdf.jsPDF(); const plainText = report.rows.map(function(row) { return row.join(' | '); }).join('\n'); const lines = doc.splitTextToSize(report.title + '\n\n' + plainText, 180); doc.text(lines, 12, 14); doc.save(report.fileBase + '.pdf'); return; } printHtmlAsPdf(report.title, report.html); } function exportAllCalculationsExcel() { exportRowsAsExcel('regenwasser-berechnungen', buildAllCalculationsExport(), 'Berechnungen'); } function exportFeeExcel() { const rows = [['Jahr', 'Versiegelt', 'Teilversiegelt', 'Abgekoppelt', 'Satz', 'Wirksame Fläche', 'Gebühr', 'Ersparnis']]; safeArray(state.gebuehren).forEach(function(item) { const calc = calculateSplitFee(item); rows.push([ String(item.jahr), String(item.versiegelt), String(item.teilversiegelt), String(item.abgekoppelt), String(item.satzProM2), String(calc.wirksameFlaeche), String(calc.gebuehr), String(calc.ersparnis) ]); }); exportRowsAsExcel('regenwasser-gebuehren', rows, 'Gebuehren'); } function exportFlaechenExcel() { const rows = [['Anlage', 'Typ', 'Bereich', 'Standort', 'Abflussfläche', 'Gebührenrelevant', 'Einzug']]; safeArray(state.anlagen).forEach(function(item) { rows.push([ item.bezeichnung, item.typ, item.grundstuecksbereich || '', item.standort || '', String(item.abflussflaeche || 0), item.versiegeltGebuehrenrelevant ? 'Ja' : 'Nein', item.einzug || '' ]); }); exportRowsAsExcel('regenwasser-flaechenaufstellung', rows, 'Flaechen'); } function ensureChartJsFallback(canvasId, message) { const canvas = document.getElementById(canvasId); if (!canvas || (window.Chart && typeof window.Chart === 'function')) { return canvas; } const parent = canvas.parentNode; if (parent) { parent.innerHTML = '
' + escapeHtml(message || 'Chart.js nicht geladen.') + '
'; } return null; } function renderRainfallChart() { const canvas = ensureChartJsFallback(CHART_NAMESPACE + '-rainfall', 'Chart.js nicht vorhanden. Zeitreihe kann ohne Bibliothek nicht gerendert werden.'); if (!canvas || !window.Chart) { return; } const data = getFilteredRainData(); const labels = data.map(function(item) { return item.datum; }); const rain = data.map(function(item) { return item.niederschlagMm; }); const temp = data.map(function(item) { return item.tempC; }); if (state.charts.rainfall) { state.charts.rainfall.destroy(); } state.charts.rainfall = new window.Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [ { type: 'bar', label: 'Niederschlag [mm]', data: rain, backgroundColor: 'rgba(13, 110, 253, 0.45)', borderColor: 'rgba(13, 110, 253, 1)', yAxisID: 'y' }, { type: 'line', label: 'Temperatur [°C]', data: temp, borderColor: 'rgba(253, 126, 20, 1)', backgroundColor: 'rgba(253, 126, 20, 0.2)', tension: 0.3, yAxisID: 'y1' } ] }, options: { maintainAspectRatio: false, responsive: true, interaction: { mode: 'index', intersect: false }, scales: { y: { title: { display: true, text: 'mm' } }, y1: { position: 'right', grid: { drawOnChartArea: false }, title: { display: true, text: '°C' } }, x: { ticks: { maxTicksLimit: 10 } } } } }); } function renderBalanceChart() { const canvas = ensureChartJsFallback(CHART_NAMESPACE + '-balance', 'Chart.js nicht vorhanden. Abflussbilanz wird ohne Bibliothek nicht gerendert.'); if (!canvas || !window.Chart) { return; } const labels = safeArray(state.anlagen).map(function(item) { return item.bezeichnung; }); const rain = safeArray(state.anlagen).map(function(item) { return item._derived ? item._derived.runoffLs : 0; }); const retention = safeArray(state.anlagen).map(function(item) { return item.speichervolumen || 0; }); const usage = safeArray(state.anlagen).map(function(item) { return item._derived && item._derived.utilizationPotential ? item._derived.utilizationPotential.usableWater : 0; }); if (state.charts.balance) { state.charts.balance.destroy(); } state.charts.balance = new window.Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [ { label: 'Abfluss [l/s]', data: rain, backgroundColor: 'rgba(13, 110, 253, 0.45)', borderColor: 'rgba(13, 110, 253, 1)' }, { label: 'Rückhaltung [m³]', data: retention, backgroundColor: 'rgba(25, 135, 84, 0.45)', borderColor: 'rgba(25, 135, 84, 1)' }, { label: 'Nutzung [m³/Jahr]', data: usage, backgroundColor: 'rgba(253, 126, 20, 0.45)', borderColor: 'rgba(253, 126, 20, 1)' } ] }, options: { maintainAspectRatio: false, responsive: true, scales: { x: { ticks: { autoSkip: false, maxRotation: 45, minRotation: 0 } } } } }); } function renderUsageChart() { const canvas = ensureChartJsFallback(CHART_NAMESPACE + '-usage', 'Chart.js nicht vorhanden. Nutzungsdiagramm wird ohne Bibliothek nicht gerendert.'); if (!canvas || !window.Chart) { return; } const result = state.ui.nutzungResult || calculateRainwaterUtilization(state.ui.nutzungInput || buildDefaultNutzungInput()); const input = state.ui.nutzungInput || buildDefaultNutzungInput(); const labels = ['Ertrag', 'Bedarf', 'Nutzbar', 'Nutzen €/a']; const data = [result.annualYield, result.annualDemand, result.usableWater, result.annualBenefit]; if (state.charts.usage) { state.charts.usage.destroy(); } state.charts.usage = new window.Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [{ label: 'Bilanz', data: data, backgroundColor: [ 'rgba(13, 110, 253, 0.45)', 'rgba(220, 53, 69, 0.35)', 'rgba(25, 135, 84, 0.45)', 'rgba(111, 66, 193, 0.45)' ], borderColor: [ 'rgba(13, 110, 253, 1)', 'rgba(220, 53, 69, 1)', 'rgba(25, 135, 84, 1)', 'rgba(111, 66, 193, 1)' ] }] }, options: { maintainAspectRatio: false, responsive: true, plugins: { tooltip: { callbacks: { label: function(context) { if (context.dataIndex === 3) { return formatCurrency(context.parsed.y); } return formatNumber(context.parsed.y, 2); } } } } } }); if (input.anlageId) { const anlage = getAnlageById(input.anlageId); if (anlage) { state.ui.selectedAnlageId = anlage.id; } } } function renderCharts() { renderRainfallChart(); renderBalanceChart(); renderUsageChart(); } function renderRoot() { if (!state.root) { return; } state.root.innerHTML = [ '
', '
', renderHeader(), renderFilterbar(), renderTabs(), '
', renderModal(), '
' ].join(''); requestAnimationFrame(function() { renderCharts(); }); } function bindRootListeners() { if (!state.root) { return; } const clickHandler = function(event) { const trigger = event.target.closest('[data-action]'); if (!trigger) { return; } const action = trigger.dataset.action; if (action === 'set-tab') { state.ui.activeTab = trigger.dataset.tab || DEFAULT_TAB; renderRoot(); return; } if (action === 'refresh-data') { loadData(); return; } if (action === 'open-modal') { openModal(trigger.dataset.modal, { id: trigger.dataset.id || '' }); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'modal-backdrop' && event.target === trigger) { closeModal(); return; } if (action === 'select-anlage') { state.ui.selectedAnlageId = trigger.dataset.id || ''; renderRoot(); return; } if (action === 'copy-hydraulik' || action === 'copy-from-anlage') { syncHydraulikInputFromAnlage(trigger.dataset.id || ''); state.ui.activeTab = 'hydraulik'; renderRoot(); return; } if (action === 'save-hydraulik') { saveCurrentHydraulikCalculation(); return; } if (action === 'seed-demo') { state.anlagen = createDemoAnlagen(); state.wartungen = createDemoWartungen(); state.genehmigungen = createDemoGenehmigungen(); state.gebuehren = createDemoGebuehren(); state.niederschlag = buildDemoRainSeries(); state.wetter = createDemoWeather(state.niederschlag); state.offlineMode = true; state.apiAvailable = false; computeDerivedState(); saveCache(); showNotice('Demo-Daten geladen.', 'success'); return; } if (action === 'delete-anlage') { if (window.confirm('Anlage wirklich löschen? Zugeordnete Wartungen und Genehmigungen werden ebenfalls entfernt.')) { deleteAnlage(trigger.dataset.id || ''); } return; } if (action === 'delete-wartung') { if (window.confirm('Wartung wirklich löschen?')) { deleteWartung(trigger.dataset.id || ''); } return; } if (action === 'delete-genehmigung') { if (window.confirm('Genehmigung wirklich löschen?')) { deleteGenehmigung(trigger.dataset.id || ''); } return; } if (action === 'open-report-preview') { openModal('report-preview', { reportType: trigger.dataset.report || state.ui.reportType }); return; } if (action === 'set-report-type') { state.ui.reportType = trigger.value || trigger.dataset.report || DEFAULT_REPORT_TYPE; renderRoot(); return; } if (action === 'export-report-pdf') { exportReportPdf(trigger.dataset.report || state.ui.reportType); return; } if (action === 'export-calculations-excel') { exportAllCalculationsExcel(); return; } if (action === 'export-flaechen-excel') { exportFlaechenExcel(); return; } if (action === 'export-gebuehren') { exportFeeExcel(); return; } if (action === 'save-gebuehren') { saveGebuehrenstand(state.ui.gebuehrenInput || buildDefaultGebuehrenInput()); return; } }; const inputHandler = function(event) { handleSearchInput(event); handleBoundInput(event); }; const changeHandler = function(event) { handleBoundInput(event); if (event.target && event.target.dataset && event.target.dataset.action === 'toggle-check') { const index = toInt(event.target.dataset.index, -1); if (index >= 0 && state.starkregen.checkliste[index]) { state.starkregen.checkliste[index].done = !!event.target.checked; saveCache(); renderRoot(); } return; } if (event.target && event.target.name === 'anlageId' && event.target.form && event.target.form.dataset.form === 'nutzung') { syncNutzungInputFromAnlage(event.target.value); return; } if (event.target && event.target.name === 'anlageId' && event.target.form && event.target.form.dataset.form === 'hydraulik') { syncHydraulikInputFromAnlage(event.target.value); state.ui.activeTab = 'hydraulik'; return; } if (event.target && event.target.name === 'typ' && event.target.form && event.target.form.dataset.form === 'anlage') { const form = event.target.form; const subtypeSelect = form.querySelector('select[name="subtype"]'); if (subtypeSelect) { const options = getSubtypeOptions(event.target.value); subtypeSelect.innerHTML = renderSelectOptions(options, options[0] || ''); } } if (event.target && event.target.dataset && event.target.dataset.action === 'set-report-type') { state.ui.reportType = event.target.value || DEFAULT_REPORT_TYPE; renderRoot(); } }; const submitHandler = function(event) { handleFormSubmit(event); }; state.root.addEventListener('click', clickHandler); state.root.addEventListener('input', inputHandler); state.root.addEventListener('change', changeHandler); state.root.addEventListener('submit', submitHandler); state.listeners.push(['click', clickHandler]); state.listeners.push(['input', inputHandler]); state.listeners.push(['change', changeHandler]); state.listeners.push(['submit', submitHandler]); } function unbindRootListeners() { if (!state.root) { return; } safeArray(state.listeners).forEach(function(item) { state.root.removeEventListener(item[0], item[1]); }); state.listeners = []; } function normalizeContainer(container) { if (!container) { return null; } if (typeof container === 'string') { return document.querySelector(container); } return container instanceof Element ? container : null; } function resetUiState() { state.ui.activeTab = DEFAULT_TAB; state.ui.modal = null; state.ui.notice = ''; state.ui.error = ''; state.ui.reportType = DEFAULT_REPORT_TYPE; state.ui.hydraulikInput = null; state.ui.hydraulikResult = null; state.ui.nutzungInput = null; state.ui.nutzungResult = null; state.ui.gebuehrenInput = null; state.ui.gebuehrenResult = null; } function buildRootElement(container) { const root = document.createElement('div'); root.id = MODULE_ID; container.innerHTML = ''; container.appendChild(root); return root; } function init(container) { const target = normalizeContainer(container); if (!target) { throw new Error('BauGenioRegenwasser.init benötigt einen gültigen Container.'); } if (state.mounted) { destroy(); } injectStyles(); destroyCharts(); resetUiState(); state.container = target; state.root = buildRootElement(target); state.mounted = true; loadCache(); ensureDemoData(); computeDerivedState(); renderRoot(); bindRootListeners(); loadData(); } function destroy() { destroyCharts(); unbindRootListeners(); clearTimeout(state.ui.searchDebounce); state.timers.forEach(function(timer) { clearTimeout(timer); clearInterval(timer); }); state.timers = []; if (state.root && state.root.parentNode) { state.root.parentNode.removeChild(state.root); } removeStyles(); state.root = null; state.container = null; state.mounted = false; } function refresh() { if (!state.mounted) { return; } loadData(); } window.BauGenioRegenwasser = { init: init, destroy: destroy, refresh: refresh }; })(); '); win.document.close(); win.focus(); } function buildSearchIndex(flaeche) { const plants = arrayify(flaeche.plants).map(function mapPlant(item) { return [item.commonName, item.botanicalName, item.remarks].join(' '); }).join(' '); return [ flaeche.name, flaeche.gebaeude, flaeche.begruenungstyp, flaeche.standortTyp, flaeche.exposition, flaeche.notes, plants ].join(' '); } function parseJsonIfNeeded(value, fallback) { if (value == null) { return fallback; } if (typeof value === 'object') { return value; } return safeJsonParse(value, fallback); } async function apiCall(endpoint, method, data) { const safeEndpoint = String(endpoint || '').replace(/^\/+/, ''); const safeMethod = String(method || 'GET').toUpperCase(); const url = API_BASE + '/' + safeEndpoint; const options = { method: safeMethod, headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }; if (safeMethod !== 'GET' && safeMethod !== 'HEAD') { options.headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(data || {}); } try { const response = await fetch(url, options); const contentType = response.headers.get('content-type') || ''; const isJson = contentType.indexOf('application/json') !== -1; const body = isJson ? await response.json() : await response.text(); if (!response.ok) { throw new Error((body && body.message) || response.statusText || 'API-Fehler'); } return { success: true, data: body, status: response.status }; } catch (error) { console.warn(MODULE_NAME + ': API-Aufruf fehlgeschlagen', safeMethod, url, error); return { success: false, data: null, error: error.message || 'Unbekannter Fehler' }; } } function normalizeLayer(item, index) { const layer = item || {}; return { id: layer.id || uid('layer'), title: layer.title || ('Schicht ' + (index + 1)), material: layer.material || '', thicknessMm: toNumber(layer.thicknessMm, 0), dryKgM2: toNumber(layer.dryKgM2, 0), wetKgM2: toNumber(layer.wetKgM2, 0) }; } function normalizePlantAssignment(item) { const plant = item || {}; const config = getPlantConfig(plant.plantId); return { id: plant.id || uid('planting'), plantId: plant.plantId || '', commonName: plant.commonName || (config ? config.commonName : ''), botanicalName: plant.botanicalName || (config ? config.botanicalName : ''), quantity: toInteger(plant.quantity, 1), spacingCm: toNumber(plant.spacingCm, 25), coverageM2: toNumber(plant.coverageM2, 0), remarks: plant.remarks || '' }; } function normalizeMonitoringRecord(item) { const record = item || {}; return { id: record.id || uid('monitor'), date: record.date || todayIso(), category: record.category || 'Vegetation', title: record.title || 'Monitoring', notes: record.notes || '', speciesObserved: record.speciesObserved || '', invasiveDetected: record.invasiveDetected || 'nein', photoCount: toInteger(record.photoCount, 0) }; } function normalizeMaintenanceEntry(item) { const entry = item || {}; return { id: entry.id || uid('pflege'), date: entry.date || todayIso(), title: entry.title || 'Pflegegang', season: entry.season || getSeasonByDate(entry.date || todayIso()), effortHours: toNumber(entry.effortHours, 0), costEur: toNumber(entry.costEur, 0), notes: entry.notes || '', photoCount: toInteger(entry.photoCount, 0), status: entry.status || 'erledigt' }; } function normalizeIrrigationZone(item, index) { const zone = item || {}; return { id: zone.id || uid('zone'), name: zone.name || ('Zone ' + (index + 1)), systemType: zone.systemType || 'tropf', runMinutes: toInteger(zone.runMinutes, 15), intervalDays: toInteger(zone.intervalDays, 2), moistureTarget: toNumber(zone.moistureTarget, 45), winterMode: parseBooleanish(zone.winterMode), sensorName: zone.sensorName || '', status: zone.status || 'ok' }; } function normalizeFundingApplication(item) { const entry = item || {}; return { id: entry.id || uid('foerderung'), programId: entry.programId || '', status: entry.status || 'eingereicht', amountRequested: toNumber(entry.amountRequested, 0), amountApproved: toNumber(entry.amountApproved, 0), submittedAt: entry.submittedAt || '', approvedAt: entry.approvedAt || '', paidAt: entry.paidAt || '', notes: entry.notes || '' }; } function normalizeFlaeche(raw) { const source = raw || {}; const typeConfig = getTypeConfig(source.begruenungstyp || 'extensiv-sedum'); const technik = parseJsonIfNeeded(source.technik, {}); const abdichtung = parseJsonIfNeeded(source.abdichtung, {}); const irrigation = parseJsonIfNeeded(source.irrigation, {}); const access = parseJsonIfNeeded(source.access, {}); const economics = parseJsonIfNeeded(source.economics, {}); const biodivMeasures = parseJsonIfNeeded(source.biodivMeasures, {}); const normalized = { id: source.id || uid('flaeche'), name: source.name || 'Unbenannte Fläche', gebaeude: source.gebaeude || '', standortTyp: source.standortTyp || typeConfig.standortTyp || 'dach', begruenungstyp: source.begruenungstyp || 'extensiv-sedum', zustand: source.zustand || 'gut', pflegestatus: source.pflegestatus || 'planmäßig', flaecheM2: toNumber(source.flaecheM2, 0), neigungGrad: toNumber(source.neigungGrad, 0), exposition: source.exposition || 'Süd', windzone: String(source.windzone || '2'), tragfaehigkeitKgM2: toNumber(source.tragfaehigkeitKgM2, 0), reserveKgM2: toNumber(source.reserveKgM2, 0), schneelastZone: String(source.schneelastZone || '2'), feuerwehrZugang: source.feuerwehrZugang || 'ja', absturzsicherung: source.absturzsicherung || 'vorhanden', abdichtung: { system: abdichtung.system || 'Bitumen', material: abdichtung.material || '', hersteller: abdichtung.hersteller || '', baujahr: abdichtung.baujahr || '', wurzelschutz: abdichtung.wurzelschutz || 'ja', fllGeprueft: abdichtung.fllGeprueft || 'unbekannt', details: abdichtung.details || '' }, schichten: arrayify(parseJsonIfNeeded(source.schichten, [])).map(normalizeLayer), technik: { substrateCm: toNumber(technik.substrateCm, typeConfig.substrateMinCm), dryLoadKgM2: toNumber(technik.dryLoadKgM2, typeConfig.saturatedLoadKgM2 * 0.6), wetLoadKgM2: toNumber(technik.wetLoadKgM2, typeConfig.saturatedLoadKgM2), snowLoadKgM2: toNumber(technik.snowLoadKgM2, getSnowLoadByZone(source.schneelastZone || '2')), runoffCoefficient: toNumber(technik.runoffCoefficient, typeConfig.runoffCoefficient), waterRetentionLPerM2: toNumber(technik.waterRetentionLPerM2, typeConfig.waterRetentionLPerM2), erosionProtection: technik.erosionProtection || '', windSuctionProtection: technik.windSuctionProtection || '', fireStripWidthCm: toNumber(technik.fireStripWidthCm, 50), rootProtectionEvidence: technik.rootProtectionEvidence || 'nicht dokumentiert', detailSolutions: technik.detailSolutions || '' }, biodivMeasures: { deadwood: parseBooleanish(biodivMeasures.deadwood), sandLens: parseBooleanish(biodivMeasures.sandLens), nestAid: parseBooleanish(biodivMeasures.nestAid), waterPoint: parseBooleanish(biodivMeasures.waterPoint), speciesRichSeedMix: parseBooleanish(biodivMeasures.speciesRichSeedMix), logs: biodivMeasures.logs || '' }, irrigation: { type: irrigation.type || 'keine', cisternConnected: parseBooleanish(irrigation.cisternConnected), frostProtection: parseBooleanish(irrigation.frostProtection), weatherControlled: parseBooleanish(irrigation.weatherControlled), sensorControlled: parseBooleanish(irrigation.sensorControlled), winterMode: parseBooleanish(irrigation.winterMode), zones: arrayify(irrigation.zones).map(normalizeIrrigationZone) }, plants: arrayify(parseJsonIfNeeded(source.plants, [])).map(normalizePlantAssignment), monitoring: arrayify(parseJsonIfNeeded(source.monitoring, [])).map(normalizeMonitoringRecord), maintenance: arrayify(parseJsonIfNeeded(source.maintenance, [])).map(normalizeMaintenanceEntry), economics: { investmentEur: toNumber(economics.investmentEur, 0), maintenancePerYearEur: toNumber(economics.maintenancePerYearEur, 0), subsidyEur: toNumber(economics.subsidyEur, 0), energySavingsPerYearEur: toNumber(economics.energySavingsPerYearEur, 0), feeReductionPerYearEur: toNumber(economics.feeReductionPerYearEur, 0), residualValueEur: toNumber(economics.residualValueEur, 0) }, access: { route: access.route || '', inspectionIntervalMonths: toInteger(access.inspectionIntervalMonths, 6), permitRequired: parseBooleanish(access.permitRequired), notes: access.notes || '' }, notes: source.notes || '', createdAt: source.createdAt || nowIso(), updatedAt: source.updatedAt || nowIso() }; if (!normalized.schichten.length) { normalized.schichten = createDefaultLayers(normalized.begruenungstyp); } if (!normalized.irrigation.zones.length) { normalized.irrigation.zones = [createDefaultIrrigationZone(1)]; } if (!normalized.plants.length) { normalized.plants = [createDefaultPlantAssignment()]; } if (!normalized.maintenance.length) { normalized.maintenance = [createDefaultMaintenanceEntry()]; } if (!normalized.monitoring.length) { normalized.monitoring = [createDefaultMonitoringRecord()]; } return normalized; } function getSnowLoadByZone(zone) { const map = { '1': 65, '1a': 75, '2': 85, '2a': 105, '3': 125 }; return map[String(zone || '2')] || 85; } function getWindZoneFactor(zone) { const map = { '1': 1.0, '2': 1.08, '3': 1.15, '4': 1.25 }; return map[String(zone || '2')] || 1.08; } function seedDemoFlaechen() { const a = createDefaultFlaeche(); a.name = 'DG Nord – Sedumdach'; a.gebaeude = 'Verwaltung Duisburg'; a.begruenungstyp = 'extensiv-sedum'; a.flaecheM2 = 380; a.exposition = 'Nord'; a.technik.substrateCm = 8; a.technik.wetLoadKgM2 = 118; a.technik.waterRetentionLPerM2 = 36; a.tragfaehigkeitKgM2 = 180; a.economics.investmentEur = 28500; a.economics.maintenancePerYearEur = 1350; a.economics.subsidyEur = 6400; a.economics.energySavingsPerYearEur = 820; a.economics.feeReductionPerYearEur = 740; a.plants = [ { id: uid('planting'), plantId: 'pfl-sedum-album', commonName: 'Weißer Mauerpfeffer', botanicalName: 'Sedum album', quantity: 1800, spacingCm: 20, coverageM2: 180, remarks: '' }, { id: uid('planting'), plantId: 'pfl-thymus-serpyllum', commonName: 'Sand-Thymian', botanicalName: 'Thymus serpyllum', quantity: 260, spacingCm: 30, coverageM2: 32, remarks: 'Artenanreicherung' } ]; a.biodivMeasures.sandLens = true; a.biodivMeasures.nestAid = true; a.irrigation.type = 'keine'; a.zustand = 'gut'; a.pflegestatus = 'planmäßig'; const b = createDefaultFlaeche(); b.name = 'HQ Süd – Solargründach'; b.gebaeude = 'Zentrale Essen'; b.begruenungstyp = 'solargruendach'; b.flaecheM2 = 560; b.exposition = 'Süd'; b.technik.substrateCm = 12; b.technik.wetLoadKgM2 = 168; b.technik.waterRetentionLPerM2 = 48; b.tragfaehigkeitKgM2 = 210; b.economics.investmentEur = 88500; b.economics.maintenancePerYearEur = 2600; b.economics.subsidyEur = 16500; b.economics.energySavingsPerYearEur = 4200; b.economics.feeReductionPerYearEur = 920; b.irrigation.type = 'tropf'; b.irrigation.cisternConnected = true; b.irrigation.weatherControlled = true; b.irrigation.sensorControlled = true; b.irrigation.zones = [ normalizeIrrigationZone({ name: 'Zone PV Ost', systemType: 'tropf', runMinutes: 18, intervalDays: 3, moistureTarget: 42, sensorName: 'FS-11', status: 'ok' }, 0), normalizeIrrigationZone({ name: 'Zone PV West', systemType: 'tropf', runMinutes: 20, intervalDays: 3, moistureTarget: 40, sensorName: 'FS-12', status: 'wartung' }, 1) ]; b.plants = [ { id: uid('planting'), plantId: 'pfl-sedum-sexangulare', commonName: 'Milder Mauerpfeffer', botanicalName: 'Sedum sexangulare', quantity: 2400, spacingCm: 20, coverageM2: 260, remarks: '' }, { id: uid('planting'), plantId: 'pfl-origanum-vulgare', commonName: 'Dost', botanicalName: 'Origanum vulgare', quantity: 400, spacingCm: 35, coverageM2: 58, remarks: 'Biodiversitätsinseln' } ]; b.biodivMeasures.deadwood = true; b.biodivMeasures.speciesRichSeedMix = true; b.zustand = 'mittel'; b.pflegestatus = 'beobachten'; const c = createDefaultFlaeche(); c.name = 'Fassade West – Living Wall'; c.gebaeude = 'Quartier West'; c.standortTyp = 'fassade'; c.begruenungstyp = 'fassade-wandgebunden'; c.flaecheM2 = 145; c.neigungGrad = 90; c.exposition = 'West'; c.technik.substrateCm = 12; c.technik.wetLoadKgM2 = 126; c.technik.waterRetentionLPerM2 = 18; c.tragfaehigkeitKgM2 = 160; c.economics.investmentEur = 72000; c.economics.maintenancePerYearEur = 4800; c.economics.subsidyEur = 9000; c.economics.energySavingsPerYearEur = 1450; c.economics.feeReductionPerYearEur = 0; c.irrigation.type = 'kapillar'; c.irrigation.weatherControlled = true; c.irrigation.sensorControlled = true; c.irrigation.zones = [ normalizeIrrigationZone({ name: 'Nordband', systemType: 'kapillar', runMinutes: 12, intervalDays: 1, moistureTarget: 55, sensorName: 'LW-01', status: 'ok' }, 0), normalizeIrrigationZone({ name: 'Südband', systemType: 'kapillar', runMinutes: 14, intervalDays: 1, moistureTarget: 58, sensorName: 'LW-02', status: 'stoerung' }, 1) ]; c.plants = [ { id: uid('planting'), plantId: 'pfl-heuchera', commonName: 'Purpurglöckchen', botanicalName: 'Heuchera micrantha', quantity: 260, spacingCm: 25, coverageM2: 40, remarks: '' }, { id: uid('planting'), plantId: 'pfl-carex-morrowii', commonName: 'Japan-Segge', botanicalName: 'Carex morrowii', quantity: 180, spacingCm: 25, coverageM2: 32, remarks: '' } ]; c.monitoring = [ normalizeMonitoringRecord({ date: todayIso(), category: 'Vegetation', title: 'Sommerkontrolle', notes: 'Leichter Trockenstress im Südband.', speciesObserved: 'Bestäuber, Spinnen', invasiveDetected: 'nein', photoCount: 8 }) ]; c.zustand = 'mittel'; c.pflegestatus = 'kritisch'; const d = createDefaultFlaeche(); d.name = 'Dachgarten Ost – Urban Gardening'; d.gebaeude = 'Campus Rhein'; d.standortTyp = 'urban'; d.begruenungstyp = 'urban-gardening'; d.flaecheM2 = 210; d.exposition = 'Ost'; d.technik.substrateCm = 32; d.technik.wetLoadKgM2 = 280; d.technik.waterRetentionLPerM2 = 76; d.tragfaehigkeitKgM2 = 340; d.economics.investmentEur = 54000; d.economics.maintenancePerYearEur = 6200; d.economics.subsidyEur = 12500; d.economics.energySavingsPerYearEur = 980; d.economics.feeReductionPerYearEur = 310; d.irrigation.type = 'sprueh'; d.irrigation.cisternConnected = true; d.irrigation.weatherControlled = true; d.irrigation.sensorControlled = false; d.irrigation.zones = [ normalizeIrrigationZone({ name: 'Hochbeete 1', systemType: 'sprueh', runMinutes: 10, intervalDays: 1, moistureTarget: 58, sensorName: '', status: 'ok' }, 0), normalizeIrrigationZone({ name: 'Hochbeete 2', systemType: 'sprueh', runMinutes: 11, intervalDays: 1, moistureTarget: 60, sensorName: '', status: 'ok' }, 1) ]; d.plants = [ { id: uid('planting'), plantId: 'pfl-tomate', commonName: 'Tomate', botanicalName: 'Solanum lycopersicum', quantity: 95, spacingCm: 45, coverageM2: 24, remarks: 'Saisonal' }, { id: uid('planting'), plantId: 'pfl-basilikum', commonName: 'Basilikum', botanicalName: 'Ocimum basilicum', quantity: 120, spacingCm: 20, coverageM2: 9, remarks: 'Saisonal' }, { id: uid('planting'), plantId: 'pfl-fragaria', commonName: 'Walderdbeere', botanicalName: 'Fragaria vesca', quantity: 140, spacingCm: 25, coverageM2: 18, remarks: '' } ]; d.zustand = 'gut'; d.pflegestatus = 'intensiv'; return [a, b, c, d].map(normalizeFlaeche); } async function loadPflanzenkatalog() { const response = await apiCall('pflanzen', 'GET'); if (response.success && Array.isArray(response.data)) { state.pflanzenkatalog = response.data.slice(); } else if (response.success && response.data && Array.isArray(response.data.items)) { state.pflanzenkatalog = response.data.items.slice(); } else { state.pflanzenkatalog = deepClone(SEED_PFLANZENKATALOG); } } async function loadFlaechen() { const response = await apiCall('flaechen', 'GET'); if (response.success && Array.isArray(response.data)) { state.flaechen = response.data.map(normalizeFlaeche); } else if (response.success && response.data && Array.isArray(response.data.items)) { state.flaechen = response.data.items.map(normalizeFlaeche); } else { const cached = readCache(); if (cached && Array.isArray(cached.flaechen) && cached.flaechen.length) { state.flaechen = cached.flaechen.map(normalizeFlaeche); } else { state.flaechen = seedDemoFlaechen(); } } } async function loadPflege() { const response = await apiCall('pflege', 'GET'); if (response.success && Array.isArray(response.data)) { state.pflegemassnahmen = response.data.map(normalizeMaintenanceEntry); return; } if (response.success && response.data && Array.isArray(response.data.items)) { state.pflegemassnahmen = response.data.items.map(normalizeMaintenanceEntry); return; } state.pflegemassnahmen = state.flaechen.reduce(function reducer(acc, flaeche) { return acc.concat(arrayify(flaeche.maintenance).map(function mapEntry(entry) { const copy = normalizeMaintenanceEntry(entry); copy.flaecheId = flaeche.id; copy.flaecheName = flaeche.name; return copy; })); }, []); } async function loadBewaesserung() { const response = await apiCall('bewaesserung', 'GET'); if (response.success && Array.isArray(response.data)) { state.bewaesserung = response.data; return; } if (response.success && response.data && Array.isArray(response.data.items)) { state.bewaesserung = response.data.items; return; } state.bewaesserung = state.flaechen.map(function mapFlaeche(flaeche) { return { flaecheId: flaeche.id, flaecheName: flaeche.name, type: flaeche.irrigation.type, zones: arrayify(flaeche.irrigation.zones).map(normalizeIrrigationZone) }; }); } async function loadFoerderungen() { const response = await apiCall('foerderungen', 'GET'); if (response.success && Array.isArray(response.data)) { state.foerderungen = response.data.map(normalizeFundingApplication); return; } if (response.success && response.data && Array.isArray(response.data.items)) { state.foerderungen = response.data.items.map(normalizeFundingApplication); return; } const cached = readCache(); if (cached && Array.isArray(cached.foerderungen)) { state.foerderungen = cached.foerderungen.map(normalizeFundingApplication); } else { state.foerderungen = []; } } async function loadWetter() { const response = await apiCall('wetter', 'GET'); if (response.success && response.data) { state.wetter = response.data; } else { state.wetter = deepClone(SEED_WETTER); } } async function loadAllData() { setLoading(true, 'Daten werden geladen'); await Promise.all([ loadPflanzenkatalog(), loadFlaechen(), loadPflege(), loadBewaesserung(), loadFoerderungen(), loadWetter() ]); setLoading(false); deriveState(); writeCache(); } async function persistFlaeche(flaeche) { const exists = state.flaechen.some(function someItem(item) { return item.id === flaeche.id; }); const method = exists ? 'PUT' : 'POST'; const endpoint = exists ? 'flaechen/' + encodeURIComponent(flaeche.id) : 'flaechen'; const payload = deepClone(flaeche); payload.updatedAt = nowIso(); const response = await apiCall(endpoint, method, payload); if (!response.success) { pushToast('Backend nicht erreichbar. Fläche lokal aktualisiert.', 'warning'); } if (exists) { state.flaechen = state.flaechen.map(function mapItem(item) { return item.id === payload.id ? normalizeFlaeche(payload) : item; }); } else { state.flaechen.unshift(normalizeFlaeche(payload)); } state.ui.selectedFlaecheId = payload.id; deriveState(); writeCache(); logActivity(exists ? 'flaeche_aktualisiert' : 'flaeche_angelegt', { id: payload.id }); return payload; } async function removeFlaeche(flaecheId) { if (!flaecheId) { return; } const response = await apiCall('flaechen/' + encodeURIComponent(flaecheId), 'DELETE'); if (!response.success) { pushToast('Backend-Löschung fehlgeschlagen. Fläche lokal entfernt.', 'warning'); } state.flaechen = state.flaechen.filter(function filterItem(item) { return item.id !== flaecheId; }); state.pflegemassnahmen = state.pflegemassnahmen.filter(function filterItem(item) { return item.flaecheId !== flaecheId; }); state.bewaesserung = state.bewaesserung.filter(function filterItem(item) { return item.flaecheId !== flaecheId; }); if (state.ui.selectedFlaecheId === flaecheId) { state.ui.selectedFlaecheId = state.flaechen.length ? state.flaechen[0].id : null; } deriveState(); writeCache(); logActivity('flaeche_geloescht', { id: flaecheId }); } async function persistFoerderung(entry) { const exists = state.foerderungen.some(function someItem(item) { return item.id === entry.id; }); const method = exists ? 'PUT' : 'POST'; const endpoint = exists ? 'foerderungen/' + encodeURIComponent(entry.id) : 'foerderungen'; const response = await apiCall(endpoint, method, entry); if (!response.success) { pushToast('Backend-Förderung nicht erreichbar. Daten lokal gesichert.', 'warning'); } if (exists) { state.foerderungen = state.foerderungen.map(function mapItem(item) { return item.id === entry.id ? normalizeFundingApplication(entry) : item; }); } else { state.foerderungen.unshift(normalizeFundingApplication(entry)); } deriveState(); writeCache(); logActivity(exists ? 'foerderung_aktualisiert' : 'foerderung_angelegt', { id: entry.id }); } async function persistPflegeEintrag(flaecheId, entry) { const flaeche = getFlaecheById(flaecheId); if (!flaeche) { return; } const exists = arrayify(flaeche.maintenance).some(function someItem(item) { return item.id === entry.id; }); const method = exists ? 'PUT' : 'POST'; const endpoint = exists ? 'pflege/' + encodeURIComponent(entry.id) : 'pflege'; const payload = Object.assign({}, entry, { flaecheId: flaecheId }); const response = await apiCall(endpoint, method, payload); if (!response.success) { pushToast('Pflegeeintrag lokal gesichert; Backend derzeit nicht verfügbar.', 'warning'); } if (exists) { flaeche.maintenance = flaeche.maintenance.map(function mapItem(item) { return item.id === entry.id ? normalizeMaintenanceEntry(entry) : item; }); } else { flaeche.maintenance.unshift(normalizeMaintenanceEntry(entry)); } deriveState(); writeCache(); logActivity(exists ? 'pflege_aktualisiert' : 'pflege_angelegt', { id: entry.id, flaecheId: flaecheId }); } async function persistMonitoringRecord(flaecheId, entry) { const flaeche = getFlaecheById(flaecheId); if (!flaeche) { return; } const exists = arrayify(flaeche.monitoring).some(function someItem(item) { return item.id === entry.id; }); const method = exists ? 'PUT' : 'POST'; const endpoint = exists ? 'monitoring/' + encodeURIComponent(entry.id) : 'monitoring'; const payload = Object.assign({}, entry, { flaecheId: flaecheId }); const response = await apiCall(endpoint, method, payload); if (!response.success) { pushToast('Monitoring lokal aktualisiert; Backend derzeit nicht verfügbar.', 'warning'); } if (exists) { flaeche.monitoring = flaeche.monitoring.map(function mapItem(item) { return item.id === entry.id ? normalizeMonitoringRecord(entry) : item; }); } else { flaeche.monitoring.unshift(normalizeMonitoringRecord(entry)); } deriveState(); writeCache(); logActivity(exists ? 'monitoring_aktualisiert' : 'monitoring_angelegt', { id: entry.id, flaecheId: flaecheId }); } function renderFlaechenList() { const items = getFilteredFlaechen(); if (!items.length) { return '
Keine Flächen gefunden. Lege eine neue Begrünungsfläche an oder setze die Filter zurück.
'; } return '' + '
' + items.map(function mapItem(item) { const active = state.ui.selectedFlaecheId === item.id ? ' is-active' : ''; return '' + '
' + '

' + escapeHtml(item.name) + '

' + '
' + '' + escapeHtml(getTypeConfig(item.begruenungstyp).label) + '' + '' + escapeHtml(item.standortTyp) + '' + renderStatusBadge(item.zustand) + '
' + '
' + '
Gebäude' + escapeHtml(item.gebaeude || '–') + '
' + '
Fläche' + formatNumber(item.flaecheM2, 1) + ' m²
' + '
Retention' + formatNumber(item.metrics.retentionVolumeM3, 2) + ' m³
' + '
Reserve' + formatNumber(item.metrics.loadReserveKgM2, 1) + ' kg/m²
' + '
' + '
'; }).join('') + '
'; } function renderLayerRows(flaeche) { return arrayify(flaeche.schichten).map(function mapLayer(layer) { return '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join(''); } function renderPlantRows(flaeche) { return arrayify(flaeche.plants).map(function mapPlant(item) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join(''); } function renderIrrigationRows(flaeche) { return arrayify(flaeche.irrigation.zones).map(function mapZone(zone) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join(''); } function renderMonitoringRows(flaeche) { return arrayify(flaeche.monitoring).map(function mapRecord(item) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join(''); } function renderFlaecheMetricsSummary(flaeche) { return '' + '
' + '
' + '

Technische Kennzahlen

' + '
' + '
Trockengewicht' + formatNumber(flaeche.metrics.dryLoadKgM2, 1) + ' kg/m²
' + '
Wassergesättigt' + formatNumber(flaeche.metrics.wetLoadKgM2, 1) + ' kg/m²
' + '
Schneelast' + formatNumber(flaeche.metrics.snowLoadKgM2, 1) + ' kg/m²
' + '
Reserve' + formatNumber(flaeche.metrics.loadReserveKgM2, 1) + ' kg/m²
' + '
Abflussbeiwert' + formatNumber(flaeche.metrics.runoffCoefficient, 2) + '
' + '
Wasserrückhalt' + formatNumber(calculateWaterRetentionPerM2(flaeche), 1) + ' l/m²
' + '
' + '
' + '
' + '

Wirkung & Betrieb

' + '
' + '
Retention' + formatNumber(flaeche.metrics.retentionVolumeM3, 2) + ' m³
' + '
CO₂-Bindung' + formatNumber(flaeche.metrics.co2KgYear, 1) + ' kg/Jahr
' + '
Kühleffekt' + formatNumber(flaeche.metrics.coolingEffect, 1) + '
' + '
Biodiversität' + formatNumber(flaeche.metrics.biodiversityIndex, 0) + '/100
' + '
Wasserbedarf' + formatNumber(flaeche.metrics.waterDemandLDay, 0) + ' l/Tag
' + '
Pflegebedarf' + formatNumber(flaeche.metrics.maintenanceNeedScore, 0) + '/100
' + '
' + '
' + '
'; } function renderFlaecheEditor(flaeche) { if (!flaeche) { return '
Noch keine Fläche ausgewählt.
'; } return '' + '
' + '
' + '
' + '

' + escapeHtml(flaeche.name) + '

' + renderStatusBadge(flaeche.zustand) + '
' + '
' + '' + '' + '' + '
' + '
' + renderFlaecheMetricsSummary(flaeche) + '
' + '
Stammdaten
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
Schichtaufbau & technische Daten
' + '
Schutzlage → Drainage → Filtervlies → Substrat → Vegetation. Gewichte und Schichtdicken direkt pflegbar.
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '' + renderLayerRows(flaeche) + '' + '
SchichtMaterialDicke mmTrocken kg/m²Nass kg/m²Aktion
' + '
' + '
' + '
' + '
' + '
Statik & Abdichtung
' + '
' + '
' + '
' + '
' + renderBoolSelect('feuerwehrZugang', flaeche.feuerwehrZugang === 'ja', '') + '
' + '
' + '
' + '
' + '
' + '
' + '
' + renderBoolSelect('abdichtung-wurzelschutz', parseBooleanish(flaeche.abdichtung.wurzelschutz), '') + '
' + '
' + renderBoolSelect('abdichtung-fllGeprueft', parseBooleanish(flaeche.abdichtung.fllGeprueft), '') + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
Pflanzenplanung & Biodiversität
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '' + renderPlantRows(flaeche) + '' + '
PflanzeStückzahlAbstand cmAbdeckung m²BemerkungAktion
' + '
' + '
' + '
' + '' + '' + '' + renderMonitoringRows(flaeche) + '' + '
DatumKategorieTitelArtenInvasiv?FotosNotizenAktion
' + '
' + '
' + '
' + '
' + '
Bewässerungssystem
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '' + renderIrrigationRows(flaeche) + '' + '
ZoneSystemLaufzeit minIntervall TageFeuchte-Ziel %SensorStatusAktion
' + '
' + '
' + '
' + '
' + '
Wirtschaftlichkeit & Betrieb
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; } function renderFlaechenView() { return '' + '
' + '
' + '

Projekt-Verwaltung (CRUD)

' + renderNavigation() + '
' + '
' + '
' + '
' + '

Flächenliste

' + '
' + renderFlaechenList() + '
' + '
' + '
' + '

Editor

' + '
' + renderFlaecheEditor(getSelectedFlaeche()) + '
' + '
' + '
' + '
' + '
'; } function renderStatikView() { const flaeche = getSelectedFlaeche(); if (!flaeche) { return '' + '
' + '

Statik & Abdichtung

' + renderNavigation() + '
' + '
Keine Fläche ausgewählt.
' + '
'; } return '' + '
' + '
' + '

Statik & Abdichtung · ' + escapeHtml(flaeche.name) + '

' + renderNavigation() + '
' + '
' + '
' + '
' + '

Lastberechnung je m²

' + '
' + '
Eigengewicht trocken' + formatNumber(flaeche.metrics.dryLoadKgM2, 1) + ' kg/m²
' + '
Eigengewicht wassergesättigt' + formatNumber(flaeche.metrics.wetLoadKgM2, 1) + ' kg/m²
' + '
Schneelast' + formatNumber(flaeche.metrics.snowLoadKgM2, 1) + ' kg/m²
' + '
Kombinierte Last' + formatNumber(flaeche.metrics.combinedLoadKgM2, 1) + ' kg/m²
' + '
Tragfähigkeit' + formatNumber(flaeche.tragfaehigkeitKgM2, 1) + ' kg/m²
' + '
Reserve' + formatNumber(flaeche.metrics.loadReserveKgM2, 1) + ' kg/m²
' + '
' + '
' + '
' + '
Reservequote: ' + formatPercent(flaeche.metrics.loadReservePercent, 1) + '
' + '
' + '
' + '
' + '

Abdichtung & Nachweise

' + '
' + '
System' + escapeHtml(flaeche.abdichtung.system || '–') + '
' + '
Material' + escapeHtml(flaeche.abdichtung.material || '–') + '
' + '
Hersteller' + escapeHtml(flaeche.abdichtung.hersteller || '–') + '
' + '
Wurzelschutz' + escapeHtml(formatBool(flaeche.abdichtung.wurzelschutz)) + '
' + '
FLL-geprüft' + escapeHtml(formatBool(flaeche.abdichtung.fllGeprueft)) + '
' + '
Nachweis' + escapeHtml(flaeche.technik.rootProtectionEvidence || '–') + '
' + '
' + '
' + escapeHtml(flaeche.abdichtung.details || 'Keine zusätzlichen Abdichtungsdetails dokumentiert.') + '
' + '
' + '
' + '

Gefährdungsbeurteilung

' + '
' + '
Windsogsicherung' + escapeHtml(flaeche.technik.windSuctionProtection || '–') + '
' + '
Erosionsschutz' + escapeHtml(flaeche.technik.erosionProtection || '–') + '
' + '
Brandschutzstreifen' + formatNumber(flaeche.technik.fireStripWidthCm, 0) + ' cm
' + '
Absturzsicherung' + escapeHtml(flaeche.absturzsicherung || '–') + '
' + '
Feuerwehrzugang' + escapeHtml(formatBool(flaeche.feuerwehrZugang === 'ja')) + '
' + '
Detailpunkte' + escapeHtml(flaeche.technik.detailSolutions ? 'Dokumentiert' : 'Offen') + '
' + '
' + '
' + '
' + '
' + '
' + '

Risikobewertung

' + renderHazardList(flaeche.metrics.hazards) + '
' + '
' + '

Empfehlung Statik-Freigabe

' + renderStatikDecision(flaeche) + '
' + '
' + '
' + '
'; } function renderHazardList(hazards) { if (!hazards.length) { return '
Keine Risiken bewertet.
'; } return '' + '
' + hazards.map(function mapHazard(item) { return '' + '
' + '
' + '' + escapeHtml(item.title) + '' + '' + escapeHtml(item.level === 'high' ? 'hoch' : (item.level === 'medium' ? 'mittel' : 'niedrig')) + '' + '
' + '
' + escapeHtml(item.description) + '
' + '
Absicherung: ' + escapeHtml(item.action) + '
' + '
'; }).join('') + '
'; } function renderStatikDecision(flaeche) { const reserve = flaeche.metrics.loadReserveKgM2; const fllOk = normalizeText(flaeche.abdichtung.fllGeprueft) === 'ja'; const windOk = Boolean(flaeche.technik.windSuctionProtection); let severity = 'info'; let title = 'Freigabe mit Vorbehalt'; let text = 'Technische Prüfung vor Ausführung vollständig dokumentieren.'; let checklist = [ 'Tragfähigkeitsnachweis archivieren', 'Wurzelschutz / FLL-Nachweis dokumentieren', 'Rand- und Anschlussdetails fotografisch sichern' ]; if (reserve < 0 || !fllOk) { severity = 'danger'; title = 'Keine Freigabe ohne Nachbesserung'; text = 'Statik oder Abdichtung ist nicht ausreichend abgesichert.'; checklist = [ 'Statische Verstärkung oder Lastreduzierung prüfen', 'FLL-Nachweis oder wurzelfeste Abdichtung nachreichen', 'Kritische Details vor Umsetzung freigeben' ]; } else if (reserve < 20 || !windOk) { severity = 'warning'; title = 'Freigabe nur mit Zusatzprüfung'; text = 'Die Reserve ist gering oder Windsogsicherung unzureichend dokumentiert.'; checklist = [ 'Sonderlasten und Schneefallereignisse separat bewerten', 'Rand- und Eckbereiche windsogsicher bemessen', 'Begehungs- und Wartungslasten ergänzend berücksichtigen' ]; } return '' + '
' + '' + escapeHtml(title) + '
' + escapeHtml(text) + '
' + ''; } function renderPlantsOverviewTable() { const flaeche = getSelectedFlaeche(); if (!flaeche) { return '
Keine Fläche ausgewählt.
'; } return '' + '
' + '' + '' + '' + arrayify(flaeche.plants).map(function mapAssignment(item) { const plant = getPlantConfig(item.plantId) || {}; return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join('') + '' + '
PflanzeBotanischKategorieWasserSubstratBiodivCO₂Hinweis
' + escapeHtml(item.commonName || plant.commonName || '–') + '' + escapeHtml(item.botanicalName || plant.botanicalName || '–') + '' + escapeHtml(plant.category || '–') + '' + escapeHtml(plant.waterNeed || '–') + '' + escapeHtml(plant.substrateMinCm ? (plant.substrateMinCm + '–' + plant.substrateMaxCm + ' cm') : '–') + '' + escapeHtml(plant.biodiversityScore != null ? String(plant.biodiversityScore) : '–') + '' + escapeHtml(plant.co2KgYear != null ? String(plant.co2KgYear) + ' kg/Jahr' : '–') + '' + escapeHtml(plant.notes || item.remarks || '–') + '
' + '
'; } function renderPlantsView() { const flaeche = getSelectedFlaeche(); if (!flaeche) { return '' + '
' + '

Pflanzen & Biodiversität

' + renderNavigation() + '
' + '
Keine Fläche ausgewählt.
' + '
'; } const invasiveFlags = arrayify(flaeche.monitoring).filter(function filterItem(item) { return normalizeText(item.invasiveDetected) === 'ja'; }); return '' + '
' + '
' + '

Pflanzen & Biodiversität · ' + escapeHtml(flaeche.name) + '

' + renderNavigation() + '
' + '
' + '
' + '
' + '

Pflanzbestand

' + '
' + '
Artenanzahl' + formatNumber(uniqueBy(flaeche.plants.filter(function filterItem(item) { return item.plantId; }), function iteratee(item) { return item.plantId; }).length, 0) + '
' + '
Pflanzmenge' + formatNumber(sumBy(flaeche.plants, function iteratee(item) { return item.quantity; }), 0) + '
' + '
Biodiversitätsindex' + formatNumber(flaeche.metrics.biodiversityIndex, 0) + '/100
' + '
Monitoring-Einträge' + formatNumber(flaeche.monitoring.length, 0) + '
' + '
' + '
' + '
' + '

Habitatmaßnahmen

' + '
' + '' + (flaeche.biodivMeasures.deadwood ? '✓' : '–') + ' Totholz' + '' + (flaeche.biodivMeasures.sandLens ? '✓' : '–') + ' Sandlinsen' + '' + (flaeche.biodivMeasures.nestAid ? '✓' : '–') + ' Nisthilfen' + '' + (flaeche.biodivMeasures.waterPoint ? '✓' : '–') + ' Wasserstelle' + '' + (flaeche.biodivMeasures.speciesRichSeedMix ? '✓' : '–') + ' Saatgutmischung' + '
' + '
' + escapeHtml(flaeche.biodivMeasures.logs || 'Keine ergänzenden Habitatnotizen hinterlegt.') + '
' + '
' + '
' + '

Invasive Arten

' + (invasiveFlags.length ? '
Mindestens ein Monitoring meldet invasive Arten. Gegenmaßnahmen priorisieren.
' : '
Keine invasiven Arten dokumentiert.
') + '
Red-Team-Hinweis: Bei bodengebundenen und urbanen Flächen ist die Früherkennung günstiger als spätere Sanierung.
' + '
' + '
' + '
' + '
' + '

Pflanzliste

' + renderPlantsOverviewTable() + '
' + '
' + '

Artenmonitoring

' + renderMonitoringTimeline(flaeche.monitoring) + '
' + '
' + '
' + '
'; } function renderMonitoringTimeline(list) { if (!arrayify(list).length) { return '
Noch keine Monitoring-Daten vorhanden.
'; } return '' + '
' + sortBy(list, function iteratee(item) { return item.date; }, 'desc').map(function mapItem(item) { return '' + '
' + '
' + escapeHtml(item.title) + ' · ' + escapeHtml(formatDate(item.date)) + '
' + '
' + escapeHtml(item.category) + ' · Arten: ' + escapeHtml(item.speciesObserved || '–') + '
' + '
Invasive Arten: ' + escapeHtml(item.invasiveDetected) + ' · Fotos: ' + escapeHtml(String(item.photoCount || 0)) + '
' + '
' + escapeHtml(item.notes || 'Keine Notiz') + '
' + '
'; }).join('') + '
'; } function renderIrrigationView() { const flaeche = getSelectedFlaeche(); if (!flaeche) { return '' + '
' + '

Bewässerungssystem

' + renderNavigation() + '
' + '
Keine Fläche ausgewählt.
' + '
'; } const status = flaeche.metrics.irrigationStatus; return '' + '
' + '
' + '

Bewässerungssystem · ' + escapeHtml(flaeche.name) + '

' + renderNavigation() + '
' + '
' + '
' + '
' + '

Systemstatus

' + '
Status' + escapeHtml(status.label) + '
' + '
' + '
Stabilitätswert: ' + formatNumber(status.percent, 0) + ' / 100
' + '
' + '
' + '

Wasserbedarf

' + '
Geschätzt pro Tag' + formatNumber(flaeche.metrics.waterDemandLDay, 0) + ' l
' + '
Abgeleitet aus Pflanzenprofil, Substrat, Systemtyp und Flächengröße.
' + '
' + '
' + '

Betriebslogik

' + '
' + '' + (flaeche.irrigation.cisternConnected ? '✓' : '–') + ' Zisterne' + '' + (flaeche.irrigation.weatherControlled ? '✓' : '–') + ' Wetterdaten' + '' + (flaeche.irrigation.sensorControlled ? '✓' : '–') + ' Feuchtesensoren' + '' + (flaeche.irrigation.frostProtection ? '✓' : '–') + ' Frostschutz' + '' + (flaeche.irrigation.winterMode ? '✓' : '–') + ' Wintermodus' + '
' + '
' + '
' + '
' + '
' + '

Bewässerungszonen

' + '
' + '' + '' + '' + arrayify(flaeche.irrigation.zones).map(function mapZone(zone) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join('') + '' + '
ZoneSystemLaufzeitIntervallZielSensorStatus
' + escapeHtml(zone.name) + '' + escapeHtml(zone.systemType) + '' + escapeHtml(String(zone.runMinutes)) + ' min' + escapeHtml(String(zone.intervalDays)) + ' Tage' + escapeHtml(String(zone.moistureTarget)) + ' %' + escapeHtml(zone.sensorName || '–') + '' + renderStatusBadge(zone.status === 'ok' ? 'gut' : (zone.status === 'wartung' ? 'mittel' : 'kritisch')) + '
' + '
' + '
' + '
' + '

Wetterbezug

' + renderWeatherWidget() + '
' + '
' + '
' + '
'; } function renderMaintenanceView() { const flaeche = getSelectedFlaeche(); if (!flaeche) { return '' + '
' + '

Pflege & Wartung

' + renderNavigation() + '
' + '
Keine Fläche ausgewählt.
' + '
'; } const entries = sortBy(flaeche.maintenance, function iteratee(item) { return item.date; }, 'desc'); return '' + '
' + '
' + '

Pflege & Wartung · ' + escapeHtml(flaeche.name) + '

' + renderNavigation() + '
' + '
' + '
' + '
' + '

Pflegestatus

' + '
Aktuell' + escapeHtml(flaeche.pflegestatus) + '
' + '
Pflegebedarfsscore: ' + formatNumber(flaeche.metrics.maintenanceNeedScore, 0) + '/100
' + '
' + '
' + '

Empfohlene Maßnahmen

' + '
' + flaeche.metrics.recommendedTasks.slice(0, 8).map(function mapTask(task) { return '' + escapeHtml(task.title) + ''; }).join('') + '
' + '
' + '
' + '

Dachbegehung & Sicherheit

' + '
' + '
Zugangsweg' + escapeHtml(flaeche.access.route || '–') + '
' + '
Intervall' + formatNumber(flaeche.access.inspectionIntervalMonths, 0) + ' Monate
' + '
Genehmigung' + escapeHtml(formatBool(flaeche.access.permitRequired)) + '
' + '
Absturzsicherung' + escapeHtml(flaeche.absturzsicherung || '–') + '
' + '
' + '
' + '
' + '
' + '
' + '

Pflegekalender

' + renderSingleCalendar(flaeche.metrics.seasonalCalendar) + '
' + '
' + '

Pflegeprotokoll

' + renderMaintenanceTable(entries) + '
' + '
' + '
' + '
'; } function renderSingleCalendar(months) { return '' + '
' + arrayify(months).map(function mapMonth(month) { return '' + '
' + '
' + escapeHtml(month.label) + '
' + '' + '
'; }).join('') + '
'; } function renderMaintenanceTable(entries) { if (!entries.length) { return '
Noch keine Pflegeprotokolle vorhanden.
'; } return '' + '
' + '' + '' + '' + entries.map(function mapEntry(item) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join('') + '' + '
DatumMaßnahmeSaisonAufwandKostenStatusFotosNotizen
' + escapeHtml(formatDate(item.date)) + '' + escapeHtml(item.title) + '' + escapeHtml(seasonLabel(item.season)) + '' + formatNumber(item.effortHours, 1) + ' h' + formatCurrency(item.costEur) + '' + renderStatusBadge(item.status) + '' + escapeHtml(String(item.photoCount || 0)) + '' + escapeHtml(item.notes || '–') + '
' + '
'; } function renderFundingPrograms() { return '' + '
' + '' + '' + '' + SEED_FOERDERPROGRAMME.map(function mapProgram(item) { return '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join('') + '' + '
ProgrammEbeneRegionFristmodellFörderhöheStatusHinweis
' + escapeHtml(item.title) + '
' + escapeHtml(item.description) + '
' + escapeHtml(item.level) + '' + escapeHtml(item.region) + '' + escapeHtml(item.deadlineModel) + '' + escapeHtml(item.amountHint) + '' + escapeHtml(item.status) + '' + escapeHtml(item.requirements) + '
' + '
'; } function renderFundingApplications() { if (!state.foerderungen.length) { return '
Noch kein Förderantrag erfasst.
'; } return '' + '
' + '' + '' + '' + state.foerderungen.map(function mapEntry(item) { const program = getFundingProgram(item.programId); return '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + ''; }).join('') + '' + '
ProgrammStatusBeantragtBewilligtEingereichtBewilligt amAusbezahlt amNotizen
' + escapeHtml(program ? program.title : 'Nicht zugeordnet') + '' + renderStatusBadge(item.status) + '' + formatCurrency(item.amountRequested) + '' + formatCurrency(item.amountApproved) + '' + escapeHtml(formatDate(item.submittedAt)) + '' + escapeHtml(formatDate(item.approvedAt)) + '' + escapeHtml(formatDate(item.paidAt)) + '' + escapeHtml(item.notes || '–') + '
' + '
'; } function renderFundingForm() { return '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
'; } function renderFundingView() { const flaeche = getSelectedFlaeche(); return '' + '
' + '
' + '

Förderung & Wirtschaftlichkeit' + (flaeche ? ' · ' + escapeHtml(flaeche.name) : '') + '

' + renderNavigation() + '
' + '
' + (flaeche ? renderFundingEconomics(flaeche) : '
Keine Fläche ausgewählt. Wirtschaftlichkeit ist flächenbezogen.
') + '
' + '
' + '

Förderprogramme

' + renderFundingPrograms() + '
' + '
' + '

Förderantrags-Tracking

' + renderFundingForm() + '
' + renderFundingApplications() + '
' + '
' + '
' + '
'; } function renderFundingEconomics(flaeche) { const lc = flaeche.metrics.lifeCycle30Years; return '' + '
' + '

Investition

Capex' + formatCurrency(flaeche.economics.investmentEur) + '
' + '

Förderquote

Anteil' + formatPercent(flaeche.metrics.fundingRatePercent, 1) + '
' + '

Gebührenreduktion

pro Jahr' + formatCurrency(flaeche.metrics.feeReductionPerYearEur) + '
' + '

LCC 30 Jahre

Netto' + formatCurrency(lc.totalCost) + '
' + '
' + '
' + '
' + '

Lebenszykluskosten

' + '
' + '
Bruttokosten 30J' + formatCurrency(lc.grossCost) + '
' + '
Einsparungen + Förderung' + formatCurrency(lc.totalSavings) + '
' + '
Netto 30J' + formatCurrency(lc.totalCost) + '
' + '
' + '
' + '
' + '

CO₂-Bilanz & Klima

' + '
' + '
CO₂-Bindung' + formatNumber(flaeche.metrics.co2KgYear, 1) + ' kg/Jahr
' + '
Kühleffekt' + formatNumber(flaeche.metrics.coolingEffect, 1) + '
' + '
Retention' + formatNumber(flaeche.metrics.retentionVolumeM3, 2) + ' m³
' + '
' + '
' + '
' + '

Red-Team-Kommentar

' + '
Die wirtschaftlich schwächste Stelle ist fast nie die Investition allein, sondern der dauerhaft unterschätzte Pflege- und Bewässerungsaufwand. Rechne konservativ, nicht marketingoptimistisch.
' + '
' + '
'; } function renderReportsView() { const flaeche = getSelectedFlaeche(); return '' + '
' + '
' + '

Berichte & Export' + (flaeche ? ' · ' + escapeHtml(flaeche.name) : '') + '

' + renderNavigation() + '
' + '
' + '
' + renderReportTile('begruenungskonzept', 'PDF-Begrünungskonzept', 'Schichtaufbau, Pflanzplan, technische Daten und Wirkungskennzahlen.', 'Konzept-PDF') + renderReportTile('pflegebericht', 'Pflegebericht', 'Pflegeprotokoll, Zustand, Fotodokumentation und Maßnahmenhistorie.', 'Pflege-PDF') + renderReportTile('statiknachweis', 'Statik-Nachweis', 'Zusatzlasten, Reserve, Abdichtung, FLL-Nachweise und Gefährdungsbeurteilung.', 'Statik-PDF') + renderReportTile('biodiversitaetsbericht', 'Biodiversitätsbericht', 'Arteninventar, Monitoring, Habitatmaßnahmen und invasive Arten.', 'Biodiversitäts-PDF') + renderReportTile('foerdernachweis', 'Fördernachweis', 'Fördertracking, Mittelverwendung und Wirtschaftlichkeitsdaten.', 'Förder-PDF') + renderReportTile('excel-export', 'Excel-/CSV-Export', 'Pflanzenlisten, Flächen, Kosten und Pflegeexport als CSV.', 'CSV exportieren') + '
' + '
' + '
' + '

Export-Historie

' + renderExportHistory() + '
' + '
' + '

Aktivitätslog

' + renderActivityLog() + '
' + '
' + '
' + '
'; } function renderReportTile(type, title, description, buttonLabel) { return '' + '
' + '

' + escapeHtml(title) + '

' + '

' + escapeHtml(description) + '

' + '
' + '' + '
' + '
'; } function renderExportHistory() { if (!state.exportHistory.length) { return '
Noch keine Exporte erstellt.
'; } return '' + '
' + state.exportHistory.slice(0, 12).map(function mapItem(item) { return '' + '
' + '
' + escapeHtml(item.title) + '
' + '
' + escapeHtml(formatDateTime(item.at)) + '
' + '
' + escapeHtml(item.filename) + '
' + '
'; }).join('') + '
'; } function renderActivityLog() { if (!state.activityLog.length) { return '
Noch keine Aktivitäten protokolliert.
'; } return '' + '
' + state.activityLog.slice(0, 12).map(function mapItem(item) { return '' + '
' + '
' + escapeHtml(item.action) + '
' + '
' + escapeHtml(formatDateTime(item.at)) + '
' + '
' + escapeHtml(item.payload ? JSON.stringify(item.payload) : '–') + '
' + '
'; }).join('') + '
'; } function renderFooter() { return '' + ''; } function renderCurrentView() { if (state.ui.view === 'dashboard') { return renderDashboardView(); } if (state.ui.view === 'flaechen') { return renderFlaechenView(); } if (state.ui.view === 'statik') { return renderStatikView(); } if (state.ui.view === 'pflanzen') { return renderPlantsView(); } if (state.ui.view === 'bewaesserung') { return renderIrrigationView(); } if (state.ui.view === 'pflege') { return renderMaintenanceView(); } if (state.ui.view === 'foerderung') { return renderFundingView(); } if (state.ui.view === 'berichte') { return renderReportsView(); } return renderDashboardView(); } function renderApp() { if (!state.root) { return; } state.root.innerHTML = '' + '
' + renderTopbar() + '
' + '' + '
' + renderCurrentView() + '
' + '
' + renderFooter() + '
'; renderToast(); } async function loadAllData() { setLoading(true, 'Daten werden geladen'); await Promise.all([ loadPflanzenkatalog(), loadFlaechen(), loadPflege(), loadBewaesserung(), loadFoerderungen(), loadWetter() ]); setLoading(false); deriveState(); writeCache(); } async function loadAllData() { setLoading(true, 'Daten werden geladen'); await loadPflanzenkatalog(); await Promise.all([ loadFlaechen(), loadFoerderungen(), loadWetter() ]); await Promise.all([ loadPflege(), loadBewaesserung() ]); setLoading(false); deriveState(); writeCache(); } async function loadAllData() { setLoading(true, 'Daten werden geladen'); await Promise.all([ loadPflanzenkatalog(), loadFlaechen(), loadPflege(), loadBewaesserung(), loadFoerderungen(), loadWetter() ]); setLoading(false); deriveState(); writeCache(); } function syncSelectedFlaecheEditor(mutator) { const flaeche = getSelectedFlaeche(); if (!flaeche) { pushToast('Keine Fläche ausgewählt.', 'warning'); return; } mutator(flaeche); deriveState(); writeCache(); renderApp(); } function snapshotEditorIntoState() { const form = state.root ? qs('#bgz-flaeche-form', state.root) : null; if (!form) { return; } const payload = collectFlaecheFormData(); if (!payload) { return; } state.flaechen = state.flaechen.map(function mapItem(item) { return item.id === payload.id ? normalizeFlaeche(payload) : item; }); deriveState(); } function syncSelectedFlaecheEditor(mutator) { snapshotEditorIntoState(); const flaeche = getSelectedFlaeche(); if (!flaeche) { pushToast('Keine Fläche ausgewählt.', 'warning'); return; } mutator(flaeche); deriveState(); writeCache(); renderApp(); } // Event handler (orphaned code block - wrapping in async IIFE for now) async function handleOrphanedActions(action, trigger) { if (action === 'save-funding') { const entry = collectFundingFormData(); if (!entry || !entry.programId) { pushToast('Bitte zuerst ein Förderprogramm auswählen.', 'warning'); return; } await persistFoerderung(entry); renderApp(); pushToast('Förderantrag gespeichert.', 'success'); return; } if (action === 'switch-view') { state.ui.view = trigger.getAttribute('data-view') || 'dashboard'; renderApp(); return; } if (action === 'switch-view') { snapshotEditorIntoState(); state.ui.view = trigger.getAttribute('data-view') || 'dashboard'; renderApp(); return; } if (action === 'select-flaeche') { state.ui.selectedFlaecheId = trigger.getAttribute('data-id'); renderApp(); return; } } // Module exports window.BauGenioBegruenung = { init: init, destroy: destroy, refresh: refresh }; })(); ' ].join(''); const printWindow = window.open('', '_blank'); if (printWindow) { printWindow.document.write(html); printWindow.document.close(); printWindow.focus(); } else { downloadText(html, 'pruefstatik_' + type + '.html', 'text/html;charset=utf-8'); } } function downloadSubmissionDeckblatt(id) { const submission = state.submissionPackages.find(function(item) { return item.id === id; }); if (!submission) { return; } exportReport('deckblatt', 'pdf', { submissionId: id, submission: submission }); } function flagStaleDocuments() { markStaleDocuments(); render(); showToast('Dokumentenstatus aktualisiert', 'Veraltete Dokumente wurden neu markiert.', 'success'); } async function handleFormSubmit(form) { const formType = form.getAttribute('data-form'); if (formType === 'position-form') { await savePosition(form); return; } if (formType === 'nachweis-form') { await saveNachweis(form); return; } if (formType === 'pruefbericht-form') { await savePruefbericht(form); return; } if (formType === 'revision-form') { await saveRevision(form); return; } if (formType === 'submission-form') { await saveSubmission(form); return; } if (formType === 'communication-form') { await saveCommunication(form); return; } if (formType === 'statement-form') { await saveStatement(form); return; } if (formType === 'baubegleitung-form') { await saveBaubegleitung(form); return; } if (formType === 'checklist-form') { await saveChecklist(form); return; } if (formType === 'sitevisit-form') { await saveSiteVisit(form); return; } if (formType === 'impact-form') { await saveImpact(form); return; } if (formType === 'wind-form') { await handleWindForm(form); return; } if (formType === 'snow-form') { await handleSnowForm(form); return; } if (formType === 'quake-form') { await handleQuakeForm(form); return; } if (formType === 'nutzlast-form') { await handleNutzlastForm(form); return; } if (formType === 'sonderlast-form') { await handleSonderlastForm(form); return; } } function buildFallbackExportRows(type) { if (type === 'positions') { return [['Positionsnummer', 'Titel', 'Typ', 'Werkstoff', 'BA', 'Geschoss', 'Status']].concat( state.positionen.map(function(item) { return [ item.positionsnummer, item.titel, labelFromCatalog('positionstypen', item.positionstyp), labelFromCatalog('werkstoffe', item.werkstoff), item.bauabschnitt, item.geschoss, labelFromCatalog('pruefstatus', getPositionStatus(item)) ]; }) ); } if (type === 'freigabeliste') { return [['Position', 'Titel', 'Status', 'Revision', 'Prüfingenieur', 'Freigabe']].concat( state.positionen.map(function(item) { const rev = sortBy(getRevisionsByPosition(item.id), function(entry){ return entry.releasedAt || entry.createdAt || ''; }, 'desc')[0]; return [ item.positionsnummer, item.titel, labelFromCatalog('pruefstatus', getPositionStatus(item)), rev ? rev.revisionCode : '—', item.pruefingenieur || '—', formatDate(item.freigegebenAm) ]; }) ); } if (type === 'loads') { return [['Position', 'Typ', 'Bezeichnung', 'Wert', 'Einheit', 'Norm', 'Kombination']].concat( state.lasten.map(function(item) { const position = getPositionById(item.positionId); return [ position ? position.positionsnummer : '', labelFromCatalog('lasttypen', item.typ), item.bezeichnung, item.wert, item.einheit, item.norm, labelFromCatalog('lastkombinationen', item.kombination) ]; }) ); } if (type === 'revisions') { return [['Revision', 'Position', 'Entity', 'Grund', 'Release']].concat( state.revisionen.map(function(item) { const position = getPositionById(item.positionId); return [ item.revisionCode, position ? position.positionsnummer : '', item.entityType + ' / ' + item.entityId, labelFromCatalog('revisionGruende', item.grund), item.isReleased ? 'ja' : 'nein' ]; }) ); } if (type === 'revision-matrix') { return [['Position', 'Nachweisversionen', 'Prüfstatus', 'Aktuelle Revision', 'Release']].concat( computeRevisionMatrix().map(function(item) { return [ item.position.positionsnummer, item.nachweisVersionen, labelFromCatalog('pruefstatus', item.pruefstatus), item.aktuelleRevision, item.freigegeben ? 'ja' : 'nein' ]; }) ); } if (type === 'pruefbericht') { const rows = [['Position', 'Prüfingenieur', 'Status', 'Einwand', 'Kategorie', 'Response']]; state.pruefberichte.forEach(function(report) { const position = getPositionById(report.positionId); if (!asArray(report.objections).length) { rows.push([ position ? position.positionsnummer : '', report.pruefingenieur || '', labelFromCatalog('pruefstatus', report.status), '', '', '' ]); return; } asArray(report.objections).forEach(function(obj) { rows.push([ position ? position.positionsnummer : '', report.pruefingenieur || '', labelFromCatalog('pruefstatus', report.status), obj.titel || '', labelFromCatalog('einwandKategorien', obj.kategorie), obj.response || '' ]); }); }); return rows; } if (type === 'deckblatt') { const rows = [['Feld', 'Wert']]; const submission = (state.submissionPackages.find(function(item){ return item.id === ((arguments[1] || {}).submissionId); }) || null); if (submission) { rows.push(['Paketnummer', submission.paketnummer]); rows.push(['Titel', submission.titel]); rows.push(['Prüfingenieur', submission.pruefingenieur || '']); rows.push(['Status', submission.status || '']); } return rows; } return [['Hinweis'], ['Kein Fallback vorhanden']]; } async function savePruefbericht(form) { const data = serializeForm(form); const objections = collectObjectionsFromForm(form); const existing = state.pruefberichte.find(function(item){ return item.id === data.id; }) || {}; const payload = normalizePruefbericht(Object.assign({}, existing, { id: data.id || uid('pb'), positionId: data.positionId, pruefingenieur: data.pruefingenieur, status: data.status, stempel: data.stempel, bearbeitungsbeginn: data.bearbeitungsbeginn ? new Date(data.bearbeitungsbeginn).toISOString() : null, bearbeitungsende: data.bearbeitungsende ? new Date(data.bearbeitungsende).toISOString() : null, objections: objections, stellungnahme: data.stellungnahme, hinweise: data.hinweise, updatedAt: nowIso() })); const saved = await requestApiSave('pruefberichte', payload, 'pruefberichte', normalizePruefbericht); const existingPosition = getPositionById(saved.positionId); if (existingPosition) { const previous = deepClone(existingPosition); existingPosition.pruefstatus = saved.status; const touched = touchPositionStatusDates(existingPosition, previous); upsertInCollection('positionen', touched, normalizePosition); calculateMetrics(); } closeModal(); render(); showToast('Prüfbericht gespeichert', 'Prüfbericht für Position wurde aktualisiert.', 'success'); } async function saveSubmission(form) { const data = serializeForm(form); const positionIds = Array.from(form.querySelectorAll('input[name="positionIds"]:checked')).map(function(input) { return input.value; }); const payload = normalizeSubmission(Object.assign({}, state.submissionPackages.find(function(item){ return item.id === data.id; }) || {}, { id: data.id || uid('sub'), paketnummer: data.paketnummer, titel: data.titel, status: data.status, pruefingenieur: data.pruefingenieur, deckblattNummer: data.deckblattNummer, eingereichtAm: data.eingereichtAm ? new Date(data.eingereichtAm).toISOString() : null, positionIds: positionIds, nachweisIds: parseCsvLine(data.nachweisIds), planReferenzen: parseCsvLine(data.planReferenzen), bemerkung: data.bemerkung })); const saved = await requestApiSave('submission-packages', payload, 'submissionPackages', normalizeSubmission); if (saved.status === 'eingereicht' || saved.status === 'in_pruefung' || saved.status === 'geprueft') { state.positionen = state.positionen.map(function(position) { if (asArray(saved.positionIds).indexOf(position.id) > -1 && mapWorkflowStatus(getPositionStatus(position)) < mapWorkflowStatus('eingereicht')) { const previous = deepClone(position); position.pruefstatus = 'eingereicht'; return touchPositionStatusDates(position, previous); } return position; }); } calculateMetrics(); closeModal(); render(); showToast('Einreichungspaket gespeichert', saved.paketnummer + ' wurde aktualisiert.', 'success'); } async function changePositionStatus(positionId, status) { const position = getPositionById(positionId); if (!position) { return; } const previous = deepClone(position); position.pruefstatus = status; const touched = touchPositionStatusDates(position, previous); await requestApiSave('positionen', touched, 'positionen', normalizePosition); calculateMetrics(); render(); showToast('Status geändert', touched.positionsnummer + ' steht jetzt auf ' + labelFromCatalog('pruefstatus', status) + '.', 'success'); } function handleFilterField(target) { const key = target.getAttribute('data-filter-field'); if (!key) { return; } state.filters[key] = target.value; if (key !== 'globalSearch' && !state.ui.selectedPositionId && state.positionen[0]) { state.ui.selectedPositionId = state.positionen[0].id; } calculateMetrics(); render(); } async function handleClickAction(actionElement, originalEvent) { const action = actionElement.getAttribute('data-action'); if (!action) { return; } if (action === 'switch-view') { state.activeView = actionElement.getAttribute('data-view') || DEFAULT_VIEW; render(); return; } if (action === 'refresh-module') { await refresh(); return; } if (action === 'reset-filters') { state.filters = { globalSearch: '', bauabschnitt: '', geschoss: '', werkstoff: '', pruefstatus: '', positionstyp: '', status: '' }; render(); return; } if (action === 'select-position') { const positionId = actionElement.getAttribute('data-position-id'); setSelectedPosition(positionId); return; } if (action === 'open-create-position') { openPositionModal(); return; } if (action === 'open-edit-position') { const positionId = actionElement.getAttribute('data-position-id'); openPositionModal(getPositionById(positionId)); return; } if (action === 'delete-position') { await deletePosition(actionElement.getAttribute('data-position-id')); return; } if (action === 'open-create-nachweis') { openNachweisModal(null, actionElement.getAttribute('data-position-id') || state.ui.selectedPositionId); return; } if (action === 'open-edit-nachweis') { const nachweis = state.nachweise.find(function(item) { return item.id === actionElement.getAttribute('data-nachweis-id'); }); openNachweisModal(nachweis, nachweis ? nachweis.positionId : state.ui.selectedPositionId); return; } if (action === 'delete-nachweis') { await deleteNachweis(actionElement.getAttribute('data-nachweis-id')); return; } if (action === 'open-create-pruefbericht') { openPruefberichtModal(null, actionElement.getAttribute('data-position-id') || state.ui.selectedPositionId); return; } if (action === 'open-edit-pruefbericht') { const report = state.pruefberichte.find(function(item) { return item.id === actionElement.getAttribute('data-report-id'); }); openPruefberichtModal(report, report ? report.positionId : state.ui.selectedPositionId); return; } if (action === 'delete-pruefbericht') { await deletePruefbericht(actionElement.getAttribute('data-report-id')); return; } if (action === 'open-create-revision') { openRevisionModal(null, { entityType: actionElement.getAttribute('data-entity-type') || 'nachweis', entityId: actionElement.getAttribute('data-entity-id') || '', positionId: actionElement.getAttribute('data-position-id') || state.ui.selectedPositionId }); return; } if (action === 'open-edit-revision') { const revision = state.revisionen.find(function(item) { return item.id === actionElement.getAttribute('data-revision-id'); }); openRevisionModal(revision); return; } if (action === 'delete-revision') { await deleteRevision(actionElement.getAttribute('data-revision-id')); return; } if (action === 'toggle-release-revision') { await toggleReleaseRevision(actionElement.getAttribute('data-revision-id')); return; } if (action === 'open-create-submission') { openSubmissionModal(); return; } if (action === 'open-edit-submission') { const submission = state.submissionPackages.find(function(item) { return item.id === actionElement.getAttribute('data-submission-id'); }); openSubmissionModal(submission); return; } if (action === 'download-deckblatt') { downloadSubmissionDeckblatt(actionElement.getAttribute('data-submission-id')); return; } if (action === 'open-communication') { openCommunicationModal(null, actionElement.getAttribute('data-position-id') || state.ui.selectedPositionId); return; } if (action === 'open-statement') { openStatementModal(actionElement.getAttribute('data-report-id'), actionElement.getAttribute('data-objection-id')); return; } if (action === 'close-objection') { await closeObjection(actionElement.getAttribute('data-report-id'), actionElement.getAttribute('data-objection-id')); return; } if (action === 'add-objection-row') { addObjectionRow(); return; } if (action === 'remove-objection-row') { removeObjectionRow(actionElement.getAttribute('data-index')); return; } if (action === 'open-create-baubegleitung') { openBaubegleitungModal(); return; } if (action === 'open-edit-baubegleitung') { const entry = state.baubegleitung.find(function(item) { return item.id === actionElement.getAttribute('data-baubegleitung-id'); }); openBaubegleitungModal(entry); return; } if (action === 'delete-baubegleitung') { await deleteBaubegleitung(actionElement.getAttribute('data-baubegleitung-id')); return; } if (action === 'open-create-checklist') { openChecklistModal(); return; } if (action === 'open-edit-checklist') { const checklist = state.checklisten.find(function(item) { return item.id === actionElement.getAttribute('data-checklist-id'); }); openChecklistModal(checklist); return; } if (action === 'add-checklist-row') { addChecklistRow(); return; } if (action === 'remove-checklist-row') { removeChecklistRow(actionElement.getAttribute('data-index')); return; } if (action === 'open-create-sitevisit') { openSiteVisitModal(); return; } if (action === 'open-edit-sitevisit') { const siteVisit = state.baustellenbegehungen.find(function(item) { return item.id === actionElement.getAttribute('data-sitevisit-id'); }); openSiteVisitModal(siteVisit); return; } if (action === 'open-create-impact') { openImpactModal(); return; } if (action === 'change-position-status') { await changePositionStatus(actionElement.getAttribute('data-position-id'), actionElement.getAttribute('data-status')); return; } if (action === 'flag-stale-docs') { flagStaleDocuments(); return; } if (action === 'export-dashboard') { await exportReport('dashboard', actionElement.getAttribute('data-format') || 'pdf'); return; } if (action === 'export-positions') { await exportReport('positions', actionElement.getAttribute('data-format') || 'excel'); return; } if (action === 'export-loads') { await exportReport('loads', actionElement.getAttribute('data-format') || 'pdf'); return; } if (action === 'export-pruefbericht') { await exportReport('pruefbericht', actionElement.getAttribute('data-format') || 'pdf'); return; } if (action === 'export-freigabeliste') { await exportReport('freigabeliste', actionElement.getAttribute('data-format') || 'pdf'); return; } if (action === 'export-revisions') { await exportReport('revisions', actionElement.getAttribute('data-format') || 'pdf'); return; } if (action === 'export-revision-matrix') { await exportReport('revision-matrix', actionElement.getAttribute('data-format') || 'excel'); return; } if (action === 'close-modal' || action === 'close-modal-backdrop') { if (action === 'close-modal-backdrop' && originalEvent && originalEvent.target !== actionElement) { return; } closeModal(); return; } } function onRootClick(event) { const actionElement = event.target.closest('[data-action]'); if (!actionElement || !state.root.contains(actionElement)) { return; } event.preventDefault(); handleClickAction(actionElement, event); } function onRootInput(event) { const target = event.target; if (target && target.matches('[data-filter-field]')) { handleFilterField(target); } } function onRootChange(event) { const target = event.target; if (target && target.matches('[data-filter-field]')) { handleFilterField(target); } } function onRootSubmit(event) { const form = event.target.closest('form[data-form]'); if (!form || !state.root.contains(form)) { return; } event.preventDefault(); handleFormSubmit(form); } function bindEvents() { unbindEvents(); state.listeners.click = onRootClick; state.listeners.input = onRootInput; state.listeners.change = onRootChange; state.listeners.submit = onRootSubmit; state.root.addEventListener('click', state.listeners.click); state.root.addEventListener('input', state.listeners.input); state.root.addEventListener('change', state.listeners.change); state.root.addEventListener('submit', state.listeners.submit); } function unbindEvents() { if (!state.root) { return; } if (state.listeners.click) { state.root.removeEventListener('click', state.listeners.click); } if (state.listeners.input) { state.root.removeEventListener('input', state.listeners.input); } if (state.listeners.change) { state.root.removeEventListener('change', state.listeners.change); } if (state.listeners.submit) { state.root.removeEventListener('submit', state.listeners.submit); } state.listeners.click = null; state.listeners.input = null; state.listeners.change = null; state.listeners.submit = null; } async function refresh() { await loadAllData(); calculateMetrics(); render(); } function resolveContainer(container) { if (container instanceof HTMLElement) { return container; } if (typeof container === 'string') { return document.querySelector(container); } return null; } async function init(container) { const target = resolveContainer(container); if (!target) { throw new Error('Container für BauGenioPruefstatik nicht gefunden.'); } state.container = target; restoreSession(); injectStyles(); target.innerHTML = '
Prüfstatik-Modul wird geladen ...
'; state.root = target; bindEvents(); await refresh(); state.initialized = true; } function destroy() { destroyCharts(); unbindEvents(); closeModal(); if (state.container) { state.container.innerHTML = ''; } removeStyles(); state.container = null; state.root = null; state.initialized = false; state.loading = false; } window.BauGenioPruefstatik = { init: init, destroy: destroy, refresh: refresh }; })(); function destroy() { destroyCharts(); unbindEvents(); closeModal(); if (state.container) { state.container.innerHTML = ''; } removeStyles(); state.container = null; state.root = null; state.initialized = false; state.loading = false; } function destroy() { destroyCharts(); unbindEvents(); state.ui.modal = null; state.ui.modalContext = null; if (state.container) { state.container.innerHTML = ''; } removeStyles(); state.container = null; state.root = null; state.initialized = false; state.loading = false; } if (newStatus === 'freigegeben' && !next.freigegebenAm) { next.freigegebenAm = nowIso(); } if (newStatus === 'freigegeben') { if (!next.geprueftAm) { next.geprueftAm = nowIso(); } if (!next.freigegebenAm) { next.freigegebenAm = nowIso(); } } function onRootClick(event) { const actionElement = event.target.closest('[data-action]'); if (!actionElement || !state.root.contains(actionElement)) { return; } event.preventDefault(); handleClickAction(actionElement, event); } function onRootClick(event) { const actionElement = event.target.closest('[data-action]'); if (!actionElement || !state.root.contains(actionElement)) { return; } event.preventDefault(); Promise.resolve(handleClickAction(actionElement, event)).catch(function(error) { showToast('Aktion fehlgeschlagen', error.message || 'Unbekannter Fehler', 'danger'); }); } function onRootSubmit(event) { const form = event.target.closest('form[data-form]'); if (!form || !state.root.contains(form)) { return; } event.preventDefault(); handleFormSubmit(form); } function onRootSubmit(event) { const form = event.target.closest('form[data-form]'); if (!form || !state.root.contains(form)) { return; } event.preventDefault(); Promise.resolve(handleFormSubmit(form)).catch(function(error) { showToast('Speichern fehlgeschlagen', error.message || 'Unbekannter Fehler', 'danger'); }); } function labelFromCatalog(key, value) { const list = CATALOGS[key] || []; const found = list.find(function(item) { return item.value === value || item.code === value; }); return found ? (found.label || found.code) : (value || '—'); } function labelFromCatalog(key, value) { const fallbackMap = { geprueft: 'Geprüft', dashboard: 'Dashboard', positions: 'Positionen', loads: 'Lastannahmen', revisions: 'Revisionen', 'revision-matrix': 'Revisionsmatrix', freigabeliste: 'Freigabeliste', pruefbericht: 'Prüfbericht', deckblatt: 'Einreichungsdeckblatt' }; const list = CATALOGS[key] || []; const found = list.find(function(item) { return item.value === value || item.code === value; }); return found ? (found.label || found.code) : (fallbackMap[value] || value || '—'); } '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); '; win.document.write(html); win.document.close(); try { win.print(); } catch (error) {} showToast('PDF bereit', 'info'); } function handleAction(action, target) { if (action === 'refresh') { loadData(true).then(renderCurrentView); return; } if (action === 'export-csv') { exportCsv(); return; } if (action === 'export-pdf') { exportPdfHtml(); return; } if (action === 'close-modal') { closeModal(); return; } if (action === 'save-modal') { saveDraft(); return; } if (action === 'nav') { state.activeView = target.getAttribute('data-view') || 'dashboard'; renderCurrentView(); return; } if (action.indexOf('open-') === 0) { openModal(action.slice(5)); return; } if (action.indexOf('edit-') === 0) { var kind = action.slice(5); var list = collectionFor(kind); var id = target.getAttribute('data-id'); for (var i = 0; i < list.length; i += 1) if (list[i].id === id) openModal(kind, list[i]); return; } if (action.indexOf('delete-') === 0) deleteEntity(action.slice(7), target.getAttribute('data-id')); } function bindEvents() { if (!state.root) return; unbindEvents(); state.listener = function (event) { var target = event.target.closest('[data-action]'); if (!target) return; handleAction(target.getAttribute('data-action'), target); }; state.inputListener = function (event) { var filterKey = event.target.getAttribute('data-filter'); var fieldKey = event.target.getAttribute('data-field'); if (filterKey) { state.filter[filterKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; renderCurrentView(); } if (fieldKey && state.draft) state.draft[fieldKey] = event.target.type === 'checkbox' ? !!event.target.checked : event.target.value; }; state.root.addEventListener('click', state.listener); state.root.addEventListener('input', state.inputListener); state.root.addEventListener('change', state.inputListener); } function unbindEvents() { if (!state.root) return; if (state.listener) state.root.removeEventListener('click', state.listener); if (state.inputListener) { state.root.removeEventListener('input', state.inputListener); state.root.removeEventListener('change', state.inputListener); } state.listener = null; state.inputListener = null; } function showToast(msg, type) { state.toasts.push({ id: uid('toast'), msg: msg, type: type || 'info' }); renderCurrentView(); setTimeout(function () { state.toasts.shift(); renderCurrentView(); }, 3500); } function ensureRoot(root) { if (root && root.nodeType === 1) return root; var existing = document.getElementById(ROOT_ID); if (existing) return existing; for (var i = 0; i < CONFIG.pageIds.length; i += 1) { var page = document.getElementById(CONFIG.pageIds[i]); if (page) { var mount = document.createElement('div'); mount.id = ROOT_ID; page.innerHTML = ''; page.appendChild(mount); return mount; } } return null; } function init(root) { state.root = ensureRoot(root); if (!state.root) return; injectStyles(); loadData(false).then(function () { renderCurrentView(); bindEvents(); }); renderCurrentView(); bindEvents(); } function destroy() { unbindEvents(); if (state.root) state.root.innerHTML = ''; state.root = null; } function refresh() { loadData(true).then(renderCurrentView); } function renderSidebarNav() { var html = '
'; for (var i = 0; i < CONFIG.views.length; i += 1) { var view = CONFIG.views[i]; html += ''; } html += '
'; return html; } function renderKpis() { var kpis = computeKpis(); var html = '
'; for (var i = 0; i < kpis.length; i += 1) html += '
' + esc(kpis[i].label) + '
' + esc(kpis[i].value) + '
' + esc(kpis[i].hint) + '
'; html += '
'; return html; } function renderEmpty(message) { return '
' + esc(message) + '
'; } function renderCell(row, column) { var value = row[column.name]; if (column.name === 'projekt') return esc(projectTitle(value)); if (column.name === 'status') return renderBadge(value); if (column.name === 'risiko') return '' + esc(value.level) + ' · ' + esc(String(value.score)) + ''; if (typeof value === 'boolean') return value ? 'Ja' : 'Nein'; if (column.type === 'date') return esc(fmtDate(value)); if (column.type === 'number') return esc(fmtNum(value, 2).replace(',00', '')); return esc(value == null || value === '' ? '—' : value); } function topColumns(schema, extraRisk) { var cols = []; for (var i = 0; i < schema.length && cols.length < 6; i += 1) cols.push({ name: schema[i].name, label: schema[i].label, type: schema[i].type }); if (extraRisk) cols.push({ name: 'risiko', label: 'Risiko', type: 'text' }); return cols; } function renderTable(title, items, columns, kind) { if (!items.length) return renderEmpty('Keine Daten in ' + title + '.'); var html = '
' + esc(title) + '
'; for (var i = 0; i < columns.length; i += 1) html += ''; html += ''; for (var rowIndex = 0; rowIndex < items.length; rowIndex += 1) { var row = items[rowIndex]; html += ''; for (var colIndex = 0; colIndex < columns.length; colIndex += 1) html += ''; html += ''; } html += '
' + esc(columns[i].label) + 'Aktionen
' + renderCell(row, columns[colIndex]) + '
'; return html; } function renderDashboard() { var risky = state.eintraege.filter(function (item) { return item.risiko && (item.risiko.level === 'hoch' || item.risiko.level === 'kritisch'); }); var projects = state.projekte.slice(0, 4); return '
' + esc(MODULE_LABEL) + '

' + esc(MODULE_LABEL) + '

Modul fuer ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise. Die Datenbasis faellt bei API-Ausfall automatisch auf Cache oder Seed-Daten zurueck.

' + renderNorms() + '
' + renderSidebarNav() + renderKpis() + renderTable('Projekte', projects, topColumns(CONFIG.schemas.projekte, false), 'projekt') + renderTable('Risikofokus', risky.slice(0, 5), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderProjekte() { return renderSidebarNav() + renderToolbar('projekt') + renderTable('Projektuebersicht', applyFilters(state.projekte), topColumns(CONFIG.schemas.projekte, false), 'projekt'); } function renderKataster() { return renderSidebarNav() + renderToolbar('eintrag') + renderTable(labelize(CONFIG.entryKey), applyFilters(state.eintraege), topColumns(CONFIG.schemas.eintraege, true), 'eintrag'); } function renderArbeitsplan() { return renderSidebarNav() + renderToolbar('plan') + renderTable(labelize(CONFIG.planKey), applyFilters(state.plaene), topColumns(CONFIG.schemas.plaene, false), 'plan'); } function renderMessung() { return renderSidebarNav() + renderToolbar('messung') + renderTable('Messungen & Pruefungen', applyFilters(state.messungen), topColumns(CONFIG.schemas.messungen, false), 'messung'); } function renderEntsorgung() { return renderSidebarNav() + renderToolbar('doku') + renderTable('Dokumentation', applyFilters(state.doku), topColumns(CONFIG.schemas.doku, false), 'doku'); } function renderBerichte() { return renderSidebarNav() + '
Berichte & Export

Letzter Sync: ' + esc(fmtDate(state.lastSync)) + '

Seed-Hinweis: ' + esc(CONFIG.seedHint) + '

Exportumfang: Projekte, ' + esc(labelize(CONFIG.entryKey)) + ', Plaene, Messungen und Nachweise.

'; } function renderModalField(field) { var value = state.draft ? state.draft[field.name] : field.default; var input = ''; if (field.type === 'select') { input = ''; } else if (field.type === 'checkbox') { input = ''; } else if (String(field.name).indexOf('bemerk') !== -1 || String(field.name).indexOf('notiz') !== -1) { input = ''; } else { input = ''; } return '
' + input + '
'; } function renderModal() { if (!state.modal) return ''; var schema = CONFIG.schemas[state.modal.schemaKey]; var html = '

' + esc(state.modal.title) + '

'; for (var i = 0; i < schema.length; i += 1) html += renderModalField(schema[i]); html += '
'; return html; } function renderToasts() { if (!state.toasts.length) return ''; var html = '
'; for (var i = 0; i < state.toasts.length; i += 1) html += '
' + esc(state.toasts[i].type.toUpperCase()) + '
' + esc(state.toasts[i].msg) + '
'; html += '
'; return html; } function renderCurrentView() { if (!state.root) return; var html = '
'; if (state.activeView === 'dashboard') html += renderDashboard(); else if (state.activeView === 'projekte') html += renderProjekte(); else if (state.activeView === 'kataster') html += renderKataster(); else if (state.activeView === 'arbeitsplan') html += renderArbeitsplan(); else if (state.activeView === 'messung') html += renderMessung(); else if (state.activeView === 'entsorgung') html += renderEntsorgung(); else html += renderBerichte(); html += '
' + renderModal() + renderToasts(); state.root.innerHTML = html; } window[MODULE_NAME] = { init: init, destroy: destroy, refresh: refresh }; })(); ';downloadBlob('blitzschutz-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Blitzschutzanlagen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Blitzschutzanlagen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Blitzschutzpläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Blitzschutzpläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Messungen & Prüfungen anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Messungen & Prüfungen bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Prüfdokumente anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Prüfdokumente bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('gasinstallation-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Gasanlagen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Gasanlagen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Gasinstallationspläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Gasinstallationspläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Dichtheit & Messungen anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Dichtheit & Messungen bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Inbetriebnahme anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Inbetriebnahme bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('schornstein-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Schornsteine anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Schornsteine bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Sanierungspläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Sanierungspläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Messungen & KÜO anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Messungen & KÜO bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Bescheide & Doku anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Bescheide & Doku bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('gebaeudeautomation-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('GA-Anlagen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('GA-Anlagen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('GA-Funktionslisten anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('GA-Funktionslisten bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Funktionsprüfungen anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Funktionsprüfungen bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('GA-Dokumentation anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('GA-Dokumentation bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('sicherheitstechnik-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Sicherheitsanlagen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Sicherheitsanlagen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Alarmpläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Alarmpläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Inspektionen & Tests anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Inspektionen & Tests bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Wartung & Doku anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Wartung & Doku bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('tiefgruendung-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Pfahlkataster anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Pfahlkataster bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Pfahlpläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Pfahlpläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Belastungen & Prüfungen anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Belastungen & Prüfungen bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Protokolle anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Protokolle bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('putz-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Putzflächen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Putzflächen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Putzpläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Putzpläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Haftzug & Ebenheit anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Haftzug & Ebenheit bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Abnahme & Doku anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Abnahme & Doku bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('glasbau-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Verglasungen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Verglasungen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Verglasungspläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Verglasungspläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Prüfungen & Nachweise anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Prüfungen & Nachweise bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Nachweise & Wartung anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Nachweise & Wartung bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('holzschutz-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Holzschutzkataster anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Holzschutzkataster bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Holzschutzpläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Holzschutzpläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Feuchte & Befall anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Feuchte & Befall bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('Gutachten & Sanierung anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('Gutachten & Sanierung bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i ';downloadBlob('betoninstandsetzung-gesamtbericht.html','text/html;charset=utf-8',html);showToast('Bericht exportiert','HTML-Datei für Druck/PDF erstellt.','success');} function showToast(title,body,tone){var host=state.root.querySelector('[data-role="toasts"]');if(!host)return;var el=document.createElement('div');el.className='bgz-toast '+(tone||'');el.innerHTML='
'+esc(title)+'
'+esc(body||'')+'
';host.appendChild(el);setTimeout(function(){if(el.parentNode)el.parentNode.removeChild(el);},3500);} function handleAction(event){var target=event.target.closest('[data-action]');if(!target||!state.root.contains(target))return;var action=target.getAttribute('data-action');if(action==='nav'){state.ui.currentView=target.getAttribute('data-view');renderView();}else if(action==='refresh'){refresh();}else if(action==='new-project'){openModal('Projekt anlegen',projectForm(null),modalFooter(''));}else if(action==='edit-project'){var p=(state.data.projekte||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(p)openModal('Projekt bearbeiten',projectForm(p),modalFooter(p.id));}else if(action==='new-main'){openModal('Instandsetzungsflächen anlegen',mainForm(null),modalFooter(''));}else if(action==='edit-main'){var m=(state.data[MAIN_KEY]||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(m)openModal('Instandsetzungsflächen bearbeiten',mainForm(m),modalFooter(m.id));}else if(action==='new-plan'){openModal('Instandsetzungspläne anlegen',planForm(null),modalFooter(''));}else if(action==='edit-plan'){var a=(state.data.plaene||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(a)openModal('Instandsetzungspläne bearbeiten',planForm(a),modalFooter(a.id));}else if(action==='new-measurement'){openModal('Karbonatisierung & Haftzug anlegen',measurementForm(null),modalFooter(''));}else if(action==='edit-measurement'){var mm=(state.data.messungen||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(mm)openModal('Karbonatisierung & Haftzug bearbeiten',measurementForm(mm),modalFooter(mm.id));}else if(action==='new-doc'){openModal('SIVV & Doku anlegen',docForm(null),modalFooter(''));}else if(action==='edit-doc'){var d=(state.data.doku||[]).find(function(i){return i.id===target.getAttribute('data-id');});if(d)openModal('SIVV & Doku bearbeiten',docForm(d),modalFooter(d.id));}else if(action==='modal-close'){closeModal();}else if(action==='form-save'){saveDraft();}else if(action==='form-delete'){if(confirm('Eintrag wirklich löschen?'))deleteEntity();}else if(action==='export-csv'){exportCsv(target.getAttribute('data-scope'));}else if(action==='export-pdf'){exportPdfHtml();}} function handleChange(event){var target=event.target.closest('[data-filter]');if(!target)return;state.ui.filter[target.getAttribute('data-filter')]=target.value;renderView();} function bindEvents(){state.root.addEventListener('click',handleAction);state.root.addEventListener('change',handleChange);state.listeners.push({type:'click',fn:handleAction});state.listeners.push({type:'change',fn:handleChange});} function unbindEvents(){for(var i=0;i '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioMedizintechnikbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioSportstaettenbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioRechenzentrumsbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioExplosionsschutz = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioHochwasserschutz = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioSolarthermie = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioAltlastensanierung = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioVerkehrssicherung = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioAnergienetz = { init: init, destroy: destroy, refresh: refresh }; })(); '; downloadBlob(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function showToast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveDraft(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteEntity(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportPdfHtml(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadFromCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); showToast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioWasseraufbereitung = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioSakralbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioSaunabadtechnik = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioHafenbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioModulbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioSeilbahntechnik = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioGewaechshausbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioReitanlagen = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioBeschussschutzbau = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioFliegendeBauten = { init: init, destroy: destroy, refresh: refresh }; })(); '; download(MODULE_KEY + '-bericht.html', 'text/html;charset=utf-8', html); } function exportJson() { download(MODULE_KEY + '-data.json', 'application/json;charset=utf-8', JSON.stringify(state.data, null, 2)); } function exportSnapshot() { var snapshot = { title: MODULE_TITLE, lastSync: state.lastSync, kpis: computeKpis(), projectTitle: HANDOFF_REFERENCE.projectTitle }; download(MODULE_KEY + '-snapshot.json', 'application/json;charset=utf-8', JSON.stringify(snapshot, null, 2)); } function toast(title, body) { var host = state.root.querySelector('[data-role="toasts"]'); if (!host) return; var el = document.createElement('div'); el.className = 'toast'; el.innerHTML = '' + esc(title) + '
' + esc(body) + '
'; host.appendChild(el); setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 3200); } function handleAction(event) { var target = event.target.closest('[data-action]'); if (!target || !state.root.contains(target)) return; var action = target.getAttribute('data-action'); if (action === 'nav') { state.ui.view = target.getAttribute('data-view') || 'dashboard'; renderShell(); renderView(); } else if (action === 'refresh') refresh(); else if (action === 'new') openModal(target.getAttribute('data-kind')); else if (action === 'edit') openModal(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'close') closeModal(); else if (action === 'save') saveModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'delete' && window.confirm('Eintrag loeschen?')) deleteModalRecord(target.getAttribute('data-kind'), target.getAttribute('data-id')); else if (action === 'csv') exportCsv(target.getAttribute('data-kind')); else if (action === 'html') exportHtml(); else if (action === 'json') exportJson(); else if (action === 'snapshot') exportSnapshot(); } function handleFilter(event) { var target = event.target.closest('[data-filter]'); if (!target) return; state.ui.filter[target.getAttribute('data-filter')] = target.value; renderView(); } function bindEvents() { state.root.addEventListener('click', handleAction); state.root.addEventListener('change', handleFilter); state.root.addEventListener('input', handleFilter); state.listeners = [{ type: 'click', fn: handleAction }, { type: 'change', fn: handleFilter }, { type: 'input', fn: handleFilter }]; } function unbindEvents() { (state.listeners || []).forEach(function (listener) { try { state.root.removeEventListener(listener.type, listener.fn); } catch (err) {} }); state.listeners = []; } function init() { if (state.mounted) return window[MODULE_NAME]; var host = document.getElementById(HOST_ID); if (!host) return null; state.host = host; host.innerHTML = ''; state.root = document.createElement('div'); state.root.id = ROOT_ID; host.appendChild(state.root); injectStyles(); if (!loadCache()) seedMock(); renderShell(); renderView(); bindEvents(); state.mounted = true; loadData().then(function () { if (state.mounted) { renderShell(); renderView(); } }); return window[MODULE_NAME]; } function destroy() { if (!state.mounted) return; unbindEvents(); if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); state.root = null; state.host = null; state.ui.modal = null; state.mounted = false; } function refresh() { return loadData().then(function () { renderShell(); renderView(); toast('Aktualisiert', MODULE_TITLE + ' neu geladen.'); return window[MODULE_NAME]; }); } window.BauGenioKlettersport = { init: init, destroy: destroy, refresh: refresh }; })();

Normen & Regelwerke

Wissensdatenbank fuer alle Gewerke — DIN, VDE, VDI, DVGW, TRGS, Eurocodes

KI-Agent