diff --git a/.gitignore b/.gitignore index e0ef1d2..a6e1ff5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ dist/ data/*.db data/backups/*.db .codex/*.log -dynamo/output/ diff --git a/AGENTS.md b/AGENTS.md index d7c67ab..e9db70a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,9 @@ Keyboard behavior: - Escape cancels - Tab / Shift+Tab navigates editable cells - Arrow keys navigate cells when not editing +- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the + selected GZF cell also shows a small table-icon button. The picked value is + committed like a typed edit. Support Ctrl+Plus and Ctrl+Shift+Plus for insertion. @@ -177,17 +180,37 @@ Column headers show the label (click to sort) with a funnel icon for the value filter; an active filter is highlighted and shows its value count. Header labels break only between words and put a trailing unit such as `[kW]` on a second line. +The project overview uses the same rule: row actions are icon-only +`IconButton`s, card-header and panel actions show icon plus text, navigation +uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons +stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter +through a collapsible PM-style filter bar (`CollapsibleFilterPanel`, +`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only +the bar collapses, its state is kept per card in `localStorage`, a collapsed +active filter shows "Filter aktiv". ## Bootstrap / Styling Rule Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals. +Cards follow the PM app look: 1px border all around, 16px radius and the +soft shadow defined on `.card` in `globals.css` (own dark-mode value). The +`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other +`shadow-*` classes or a colored top border to cards. + Do not use Bootstrap as the core table/grid framework for the circuit list editor. The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior. Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing. +## Browser Runtime + +The web UI is also opened over plain HTTP from other machines in the LAN, which +is not a secure context. Do not call secure-context-only browser APIs such as +`crypto.randomUUID()` directly; create client-side ids with `createClientId()` +from `src/frontend/utils/client-id.ts`. + ## Drag-and-Drop Rules Dragging from the circuit identifier / circuit handle moves the whole circuit. @@ -261,8 +284,16 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -Current snapshot schema version 5 additionally stores the manual quantity share -of every CircuitDeviceRow. Version 4 snapshots are upgraded with +Current snapshot schema version 7 additionally stores the project buildings +and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6 +are upgraded with no buildings and `buildingId = null`. Version 6 stores the +distribution-board info fields `boardNumber`, `location`, `fedFrom` and +`remark` and the project cover fields `siteAddress`, `workStage`, +`projectLead` and `projectEditor`; all older supported versions are upgraded +with `null` for these fields. +Stored named and automatic snapshots of every supported version (1 to 7) +remain restorable; they are upgraded while parsing. Version 5 stores +the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed external-model source, import batches, room mappings and objects. Version 3 snapshots are upgraded with an empty external-model state; version 2 snapshots @@ -276,7 +307,7 @@ project graph. Project-wide Revit CSV configuration uses `external-csv-configuration.update`. Its complete expected/target snapshot is persisted through the shared project-command transaction, so create, update, -delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable +delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. The project page exposes the Revit CSV modal. It edits the complete @@ -290,7 +321,7 @@ immutable import batches with original bytes plus the classified cell matrix, normalized source-room mappings and external objects unique by `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. The general external-model repository is read-only; runtime writes belong only -to typed command adapters. Snapshot/restore and portable project transfer schema version 5 +to typed command adapters. Snapshot/restore and portable project transfer schema version 5/6 capture the complete external state and remap every internal UUID and link while preserving IFCGUIDs, source values, original bytes and the classified matrix. Import batches additionally store the monotonic project CSV configuration @@ -379,6 +410,10 @@ Project settings use the persistent `project.update-settings` command. Project metadata, the public-building classification, both voltage defaults and the enabled distribution-board supply types change in one revision and Undo/Redo restores them together; the project PUT route requires `expectedRevision`. +Schema version 3 additionally carries the print cover fields `siteAddress`, +`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2 +(without them) and baseline version 1 stay executable and keep the current +cover values. The PUT route accepts the cover fields optionally. The public-building flag is the future sizing input for requiring halogen-free cables and lines. The system catalog is `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board @@ -433,6 +468,18 @@ the only supported circuit-protection source; no flat compatibility fields remai Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. +The same command (still schema version 1, additively extended) carries the +board info-card fields `boardNumber`, `location`, `fedFrom` and `remark` +(migration `0007`). They are nullable, trimmed planner values; the PUT route +only includes submitted info fields in the command and its inverse. An empty +board with info values is no longer an unchanged setup, so history deletion of +it is rejected like a changed simultaneity factor. Populated-subtree commands +stored before these fields existed are normalized to `null` values +(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free +text: the logical link to the feeding board/circuit is an open design decision. +These fields are intended to be prefillable from Revit parameters later, but +only through the Phase 14 rules (preview, explicit confirmation, no silent +overwrite). Floor and room setup uses persistent insert, update and delete commands with complete stable snapshots. Their inverses remove or restore only unchanged records and reject floors with assigned rooms/distribution boards or rooms referenced by @@ -454,8 +501,49 @@ Required operations: - synchronization changes +## Print View (Seitenansicht) + +- Route `src/app/projects/[projectId]/print/page.tsx`, opened from the project + page button "Seitenansicht". Read-only: it only uses existing GET endpoints. +- Pure read model `src/frontend/print/print-document-model.ts` (tested in + `tests/print-document-model.test.ts`); React sheets in + `src/frontend/print/print-document.tsx`; styles scoped to `.print-view` in + `src/frontend/print/print-document.css`. +- Pages: cover (A4 portrait, tabular), board overview with structure and + power sums, one plan per board (A4/A3 landscape) whose title block sits in + `` so it repeats on printed pages. PDF export uses the browser print + dialog; page size, margins and "Seite x von y" come from injected named + `@page` rules (`lb-portrait`, `lb-landscape`). +- The board hierarchy and "Σ inkl. unterlagert" in the overview are + PRELIMINARY (text match of `fedFrom` against board number/name, plain sum, + no upstream simultaneity factor). Replace this once the feed link is modeled. +- Colors/fonts follow the kbp corporate design (`--pd-*` variables in + `print-document.css`). Empty cover values are shown as + "[ergänzen]"; the Planstände editor column shows the project's current + `projectEditor` because snapshots do not record an editor. +- The document logo and the office profile (office name/address on the + cover) are global office branding: tables `document_branding_assets` + (migration `0007`) and `document_branding_settings` (migration `0008`), + endpoints `GET|PUT|DELETE /api/document-branding/logo`, + `PUT /api/document-branding/office` and `GET /api/document-branding`. + It is intentionally outside project revisions, snapshots and transfer. + Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo + is served with `nosniff` and a restrictive CSP. + ## Revit / CSV External Model Work +Status: further Revit work (editor assignment UI, follow-up import, return +export) is deferred and will be tackled later. When it resumes, the exchange +stays CSV-based and uses the CSV export/import of the Linear plugin in Revit. +The internal Revit/CAD steering committee prefers this over manipulating the +model directly through Dynamo. Do not start Dynamo, Revit API or JSON +transport work. The Linear CSV layout is not yet known: check a sample export +against the existing project CSV configuration before changing the parser. +Earlier Dynamo considerations are archived in +`docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md`; the +removed diagnostic scripts remain reachable through the git tag +`archive/dynamo-vorueberlegungen`. + When explicitly working on Phase 14, use `docs/spec/revit-csv-integration-requirements.md` as the detailed source of truth together with the current architecture documents. @@ -519,10 +607,13 @@ circuit list (see `docs/cable-sizing-module.md`). Rules: by clicking a grid cell; cable cells keep the normal spreadsheet behavior. - It has no database table and no migration; `/api/cable-sizing/*` is stateless. -- Reference data is IEC 60364-5-52:2009 Annex B. Change table values only with - a documented source in `docs/cable-sizing-module.md`; where sources differ, - use the lower value. Inputs outside the tables return no result instead of - assuming a factor of 1. +- Current-carrying capacities and correction factors use + DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the + separate basis of voltage-drop and protection checks are documented in + `docs/cable-sizing-module.md`. Inputs outside the supported tables return no + result instead of assuming a factor of 1. Direct burial, grouped method E, + aluminium below 25 mm² and neutral-current sizing above 33 % third harmonic + remain unsupported until their required inputs or normative basis exist. - Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ, length, protective device) and remain editable; manual cable entry without a calculation stays possible. @@ -549,6 +640,30 @@ Future sizing will need: Users must be able to override sizing suggestions. +## Estimate Balance Module (Grobe Leistungsbilanz) + +`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module +for the early-phase power balance by DIN 276 cost group (see +`docs/estimate-balance.md`). Rules: + +- It has its own tables (`estimate_*`) and never writes circuit, board or + project-device data; it only reads device rows for the comparison. +- Writes use the project command mechanism (`estimate-balance.insert/.update/ + .delete` with complete documents), so revision checks and Undo/Redo apply. +- The calculation and comparison live in the import-free + `src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI; + displayed values use `roundEstimateValue`. +- It is deliberately not part of named/automatic project snapshots or snapshot + restore; those operations leave estimate balances unchanged. Portable project + transfer format version 2 includes every estimate balance and remaps balance, + group and row ids when duplicating a project. Version 1 transfers remain + importable and contain no estimate balances. +- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor, + `src/frontend/estimate-balance/`). The editor autosaves the whole document + debounced (one Undo step per save), keeps invalid raw input local, and on a + 409 reloads only if the balance itself changed; otherwise it re-saves with + the new revision. `request()` throws `ApiRequestError` (status, code). + ## Persistence and Migration Rules - SQLite is the currently supported database. @@ -566,11 +681,15 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and +- Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never + Dynamo), except when Phase 14 is explicitly requested and `docs/spec/revit-csv-integration-requirements.md` is being followed - full electrical sizing - multi-user/PostgreSQL operation - supported production deployment +- logical feed link between distribution boards (`fedFrom` stays free text + until the project owner decides whether it references a board, a feeding + circuit or both) and the resulting hierarchical power summation Do not implement these while working on an unrelated phase. diff --git a/README.md b/README.md index 58f1832..b3c026e 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,14 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung. Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise, Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt. -Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb -mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, -Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht -implementiert. +Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein +Docker-Server-Stack mit gebautem Anwendungscode und ein separater +Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment +und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau +und bestätigter Erstimport sind implementiert; die Editoroberfläche zur +Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere +Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import +des Linear-Plugins in Revit. ## Unterstützter Arbeitsablauf @@ -25,6 +29,11 @@ implementiert. - Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen - benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen - automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen +- Seitenansicht mit Deckblatt, Verteilerübersicht und Verteilerplänen öffnen + und über den Browser als PDF drucken; Büroangaben und Logo zentral pflegen +- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von + Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen + noch keine Stromkreise oder Gerätezeilen ## Technik @@ -142,6 +151,7 @@ vor der Baseline werden nicht unterstützt. - [Aktuelle Architektur](docs/current-architecture.md) - [Editor-Interaktionen](docs/circuit-list-editor-interactions.md) - [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md) +- [Seitenansicht und PDF-Export](docs/print-view.md) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md) diff --git a/docs/README.md b/docs/README.md index 5e37408..c04103a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,12 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we fachliches Modell und Grid-Aufteilung - [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur, Drag-and-drop, Sortierung, Filterung und Undo/Redo +- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt, + Verteilerübersicht, Verteilerpläne, Büro-Logo +- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume, + Befehle, Snapshot-Schema 7, eigene Seite +- [Grobe Leistungsbilanz](estimate-balance.md) – LPH 2/3 nach Kostengruppen, + Rechnung, Vergleich mit der detaillierten Bilanz - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – bewusst noch nicht implementierte oder begrenzte Funktionen @@ -27,7 +33,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) - [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md) -- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) +- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) – + zurückgestellt; später über den CSV-Export/-Import des Linear-Plugins - [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md) - [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md) - [Roadmap und Phasen](spec/07-implementation-phases-todo.md) @@ -50,3 +57,6 @@ Limitierungen und Roadmap. - [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer Arbeitsstand, keine aktuelle Implementierungsanweisung +- [Revit-/Dynamo-Anbindung](archive/revit-dynamo-leistungsbilanz-integration-konzept.md) – + nicht weiterverfolgte Vorüberlegung zur direkten Dynamo-/API-Anbindung; + Diagnoseskripte über den Git-Tag `archive/dynamo-vorueberlegungen` diff --git a/docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md b/docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md new file mode 100644 index 0000000..aa0ae97 --- /dev/null +++ b/docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md @@ -0,0 +1,1152 @@ +# Revit-/Dynamo-Anbindung an die Leistungsbilanz + +> **Archiviert (September 2026), keine aktuelle Implementierungsanweisung.** +> Die direkte Revit-Anbindung über Dynamo/Python, Revit-API und JSON wird nicht +> weiterverfolgt. Die weitere Revit-Arbeit ist zurückgestellt und erfolgt, wenn +> sie wieder aufgenommen wird, über den CSV-Export/-Import des Linear-Plugins +> in Revit. Siehe +> [Revit-CSV-Anforderungen](../spec/revit-csv-integration-requirements.md). +> Die zugehörigen Dynamo-Diagnoseskripte sind über den Git-Tag +> `archive/dynamo-vorueberlegungen` erreichbar. + +## Zweck dieses Dokuments + +Dieses Dokument beschreibt den aktuellen Stand und die gewünschte Zielrichtung für die Anbindung von Revit an die Leistungsbilanz-App. + +Es soll als Arbeitsgrundlage für Codex dienen. Codex soll **zuerst die bestehende Architektur des Repositories analysieren** und danach konkrete Vorschläge für: + +1. die API-Struktur der Leistungsbilanz, +2. das Austauschformat zwischen Revit und Leistungsbilanz, +3. die benötigten Dynamo-/Python-Skripte, +4. die Synchronisationslogik Revit → Leistungsbilanz, +5. die spätere Rückrichtung Leistungsbilanz → Revit + +erarbeiten. + +Die nachfolgend genannten Klassennamen, Endpoints und DTOs sind **Vorschläge und keine zwingenden Vorgaben**. Sie sollen an die tatsächlich vorhandene Architektur angepasst werden. + +--- + +# 1. Ausgangssituation + +Die Leistungsbilanz-App soll Daten aus Revit übernehmen können. + +Ursprünglich war dafür ein CSV-basierter Import vorgesehen, weil Revit-Bauteillisten nativ als CSV exportiert werden können. + +Inzwischen ist klar, dass Dynamo mit Python genutzt werden kann und damit ein direkter Zugriff auf die Revit API möglich ist. + +Dadurch ist eine wesentlich sauberere direkte Integration möglich. + +Die CSV-Schnittstelle soll deshalb **nicht zwingend entfallen**, aber künftig eher als: + +- manueller Importweg, +- Fallback, +- allgemeine Austauschmöglichkeit, +- Debug-/Testformat + +verstanden werden. + +Die primäre Revit-Integration soll perspektivisch über eine **versionierte API mit JSON als Austauschformat** erfolgen. + +--- + +# 2. Aktueller Dynamo-Stand + +Ein erster Dynamo-Python-Test funktioniert bereits. + +Aktuell werden über die Revit API alle Exemplare der Kategorie: + +```text +BuiltInCategory.OST_ElectricalFixtures +``` + +also Revit **Elektroinstallationen / Electrical Fixtures** gesammelt. + +Für den aktuellen Entwicklungsstand soll bewusst zunächst **nur diese Kategorie** betrachtet werden. + +Andere Kategorien können später ergänzt werden. + +--- + +# 3. Bereits erfolgreich aus Revit gelesene Daten + +Der aktuelle Python-Ansatz kann bereits folgende Daten ausgeben: + +```text +ElementId +UniqueId +Familie +Typ +MEP_Raumnummer +MEP_Raumname +IFC_GUID +``` + +Die Zuordnung zum MEP-Raum erfolgt über den zum FamilyInstance gehörenden MEP Space. + +Der aktuelle Test zeigt, dass das grundsätzliche Auslesen über Dynamo/Python funktioniert. + +--- + +# 4. Relevante Identitäten + +Für die Kopplung zwischen Revit und Leistungsbilanz sollen mehrere Identifikatoren berücksichtigt werden. + +## 4.1 Revit UniqueId + +Die `UniqueId` soll die primäre Revit-seitige Elementidentität darstellen. + +Sie sollte für die dauerhafte Zuordnung eines Revit-Elements verwendet werden. + +Beispiel: + +```text +revitUniqueId = "0a52cc55-..." +``` + +--- + +## 4.2 ElementId + +Die Revit `ElementId` soll ebenfalls übertragen werden. + +Sie ist nützlich für: + +- Debugging, +- Fehlersuche, +- Anzeige für den Anwender, +- direkte Suche eines Elements in Revit. + +Sie soll aber **nicht als alleiniger persistenter Schlüssel** zwischen den Systemen verwendet werden. + +--- + +## 4.3 IFC GUID + +Die IFC GUID soll ebenfalls mitgeführt werden, sofern sie im Revit-Modell vorhanden ist. + +Damit entsteht perspektivisch eine Verbindung: + +```text +Revit + ↕ UniqueId +Leistungsbilanz + ↕ IFC GUID +IFC +``` + +Die IFC GUID ist damit insbesondere für spätere IFC-basierte Integrationen relevant. + +--- + +# 5. Zusätzlich benötigte Modellidentität + +Die `UniqueId` identifiziert ein Element innerhalb eines Revit-Modells. + +Deshalb benötigt die Leistungsbilanz zusätzlich eine stabile Identität des Revit-Modells. + +Vorgeschlagener Schlüssel: + +```text +revitModelId +``` + +Beispiel: + +```text +550e8400-e29b-41d4-a716-446655440000 +``` + +Diese ID darf nicht ausschließlich aus folgenden Informationen erzeugt werden: + +```text +Dateiname +Dateipfad +lokaler Speicherort +``` + +da sich diese ändern können. + +Mögliche Speicherorte in Revit: + +### Variante A – Projektparameter + +Zum Beispiel: + +```text +LB_ProjectId +LB_ModelId +``` + +Vorteile: + +- einfach umzusetzen, +- sichtbar, +- leicht zu debuggen, +- direkt über Dynamo lesbar. + +### Variante B – Revit Extensible Storage + +Langfristig eleganter, weil integrationsspezifische Metadaten unsichtbar im Modell gespeichert werden können. + +Beispielsweise: + +```text +LeistungsbilanzConnector +{ + modelId + projectId + lastSync +} +``` + +Für einen ersten Prototyp ist ein Projektparameter wahrscheinlich ausreichend. + +--- + +# 6. Grundprinzip der Zielarchitektur + +Die Revit-Schnittstelle soll nicht direkt an interne Datenbanktabellen gekoppelt werden. + +Empfohlen wird: + +```text +┌──────────────────────────── REVIT ────────────────────────────┐ +│ │ +│ Revit API │ +│ ↓ │ +│ Dynamo + Python │ +│ ↓ │ +│ Revit-Adapter │ +│ ↓ │ +│ definiertes Austauschmodell │ +│ ↓ │ +│ JSON │ +│ │ +└───────────────────────┬───────────────────────────────────────┘ + │ + │ HTTP / REST + ▼ +┌──────────────────── LEISTUNGSBILANZ ──────────────────────────┐ +│ │ +│ Revit Integration API │ +│ ↓ │ +│ Import-/Sync-Service │ +│ ↓ │ +│ persistierte Revit-Referenz │ +│ ↓ │ +│ Zuordnung zu DeviceRow / Circuit / bestehendem Datenmodell │ +│ │ +└───────────────────────────────────────────────────────────────┘ +``` + +Wichtig: + +> JSON ist das Austauschformat. HTTP ist nur der Transportweg. + +Dasselbe Datenmodell soll deshalb bei Bedarf auch als JSON-Datei gespeichert und eingelesen werden können. + +--- + +# 7. Austauschmodell / DTO + +Es soll ein klar definiertes und versioniertes Austauschformat geben. + +Beispiel: + +```json +{ + "schemaVersion": 1, + "source": "revit", + "model": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "26009_F60_ELT" + }, + "elements": [ + { + "uniqueId": "f4d3c...", + "elementId": 471123, + "ifcGuid": "2X9...", + "category": "Electrical Fixtures", + "family": "Steckdose JUNG LS990", + "type": "Schuko 1-fach", + "space": { + "number": "1.03", + "name": "Schlafen" + }, + "parameters": { + "power": 250, + "cosPhi": 0.95 + } + } + ] +} +``` + +Das tatsächliche Schema soll durch Codex anhand des bestehenden Datenmodells der Leistungsbilanz konkretisiert werden. + +--- + +# 8. Trennung zwischen Revit-Objekt und Leistungsbilanz-Objekt + +Ein Revit-Element soll **nicht direkt mit einer DeviceRow gleichgesetzt werden**. + +Das ist wichtig, weil: + +- Revit den geplanten Modellstand enthält, +- Nutzer Werte in der Leistungsbilanz überschreiben können, +- ein importiertes Revit-Element erst vom Nutzer einem bestehenden oder neuen Stromkreis bzw. einer DeviceRow zugeordnet werden kann, +- ein erneuter Sync keine bewussten Benutzeränderungen überschreiben darf. + +Konzeptionell sollte deshalb eine separate Revit-Referenz existieren. + +Beispiel: + +```text +RevitElement + │ + │ verknüpft mit + ▼ +LinkedDevice / Zuordnung + │ + ▼ +DeviceRow + │ + ▼ +Circuit +``` + +Die tatsächliche Umsetzung muss Codex mit der vorhandenen Architektur abgleichen. + +Mögliche Eigenschaften einer persistierten Revit-Referenz: + +```text +id +revitModelId +revitUniqueId +revitElementId +ifcGuid + +category +family +type + +spaceNumber +spaceName + +rawData +lastSeenAt +lastSyncAt +``` + +Die Bezeichnung `RevitElement` ist nur ein Vorschlag. + +Wenn es im aktuellen Datenmodell bereits eine passende Entität gibt, soll diese verwendet oder erweitert werden. + +--- + +# 9. Revit → Leistungsbilanz + +Der erste zu implementierende Sync soll in diese Richtung erfolgen. + +```text +Revit + ↓ +Dynamo + ↓ +Python / Revit API + ↓ +JSON Payload + ↓ +HTTP POST + ↓ +Leistungsbilanz API + ↓ +Sync-Service +``` + +## Vorgeschlagenes Verhalten + +Der Benutzer startet in Dynamo bewusst einen Export bzw. Sync. + +Das Python-Skript: + +1. liest alle relevanten `OST_ElectricalFixtures`, +2. liest die definierten Revit-Daten, +3. erzeugt das versionierte JSON, +4. sendet es an die Leistungsbilanz, +5. wertet die HTTP-Antwort aus, +6. zeigt dem Benutzer das Ergebnis in Dynamo an. + +--- + +# 10. Snapshot-Sync statt Delta-Sync + +Für den ersten Stand soll **kein komplexer Delta-Sync** gebaut werden. + +Stattdessen soll Revit bei jedem Sync einen vollständigen Snapshot aller aktuell relevanten Elektroinstallationen liefern. + +Beispiel: + +```text +vorher aktueller Snapshot +------------------------------------------------ +Element A vorhanden Element A vorhanden +Element B vorhanden Element B vorhanden +Element C vorhanden Element C fehlt + Element D neu +``` + +Die Leistungsbilanz kann daraus ableiten: + +```text +A → unchanged oder updated +B → unchanged oder updated +C → missing / deleted / not seen +D → created +``` + +Das ist für zunächst einige hundert oder tausend Elemente ausreichend und deutlich robuster. + +--- + +# 11. Upsert-Verhalten + +Auf Leistungsbilanzseite soll ein Revit-Element anhand eines stabilen Schlüssels identifiziert werden. + +Vorgeschlagen: + +```text +(revitModelId, revitUniqueId) +``` + +Beim Empfang eines Snapshots: + +```text +Element bereits vorhanden? + │ + ├─ Nein + │ ↓ + │ Create + │ + └─ Ja + ↓ + Update +``` + +Die Revit-Referenz soll dabei aktualisiert werden. + +Benutzerseitig überschriebene Werte einer DeviceRow dürfen dadurch jedoch nicht ungeprüft überschrieben werden. + +--- + +# 12. Mögliche API-Struktur + +Nicht zwingend exakt so umsetzen. + +Codex soll prüfen, wie die aktuelle API strukturiert ist. + +Mögliche Endpoints: + +```http +POST /api/integrations/revit/v1/models/{modelId}/sync +``` + +Alternativ logisch getrennt: + +```http +POST /api/integrations/revit/v1/models/{modelId}/elements +GET /api/integrations/revit/v1/models/{modelId}/writeback +``` + +Beispielantwort auf einen Revit-Sync: + +```json +{ + "syncId": "sync-9384", + "received": 483, + "created": 17, + "updated": 45, + "unchanged": 421, + "missing": 3 +} +``` + +Die konkrete Antwortstruktur soll so gewählt werden, dass Dynamo dem Nutzer eine verständliche Zusammenfassung anzeigen kann. + +--- + +# 13. Leistungsbilanz → Revit + +Die Rückrichtung soll später ebenfalls unterstützt werden. + +Sie soll jedoch zunächst **logisch vom Revit-Import getrennt** werden. + +Also: + +```text +PUSH + +Revit + ↓ +Leistungsbilanz +``` + +und: + +```text +PULL / WRITEBACK + +Leistungsbilanz + ↓ +Revit +``` + +Der Benutzer soll bewusst entscheiden können, wann Daten nach Revit zurückgeschrieben werden. + +Zum Beispiel: + +```text +[ Revit-Daten importieren ] + +[ Änderungen nach Revit schreiben ] +``` + +--- + +# 14. Writeback nicht generisch ausführen + +Die Leistungsbilanz soll nicht beliebige Parameter-Namen an Revit schicken, die Dynamo anschließend blind beschreibt. + +Stattdessen soll es ein definiertes Writeback-Schema geben. + +Beispiel: + +```json +{ + "updates": [ + { + "uniqueId": "abcdef...", + "values": { + "circuitNumber": "UV01-2F17", + "panel": "UV01", + "connectedLoad": 250 + } + } + ] +} +``` + +Der Revit-Adapter besitzt anschließend eine feste Allowlist bzw. ein Mapping: + +```text +circuitNumber + ↓ +LB_Stromkreisnummer + +panel + ↓ +LB_Verteiler + +connectedLoad + ↓ +LB_Anschlussleistung +``` + +Damit ist kontrolliert, welche Revit-Parameter durch die Leistungsbilanz beschrieben werden dürfen. + +--- + +# 15. Field Ownership / Datenhoheit + +Für bidirektionale Synchronisation muss definiert sein, welches System für welches Feld führend ist. + +Beispiel: + +| Information | Führendes System | +|---|---| +| Revit UniqueId | Revit | +| Revit ElementId | Revit | +| IFC GUID | Revit | +| Familie | Revit | +| Typ | Revit | +| MEP Raum | Revit | +| MEP Raumnummer | Revit | +| Zuordnung zu DeviceRow | Leistungsbilanz | +| Zuordnung zu Circuit | Leistungsbilanz | +| manuell überschriebene Leistung | Leistungsbilanz | +| Sicherung | Leistungsbilanz | +| Leitung | Leistungsbilanz | +| Stromkreisnummer | Leistungsbilanz, ggf. Writeback | +| Verteilerzuordnung | Leistungsbilanz, ggf. Writeback | + +Diese Tabelle muss anhand des bestehenden Datenmodells vervollständigt werden. + +Ziel ist, Konflikte wie diesen zu vermeiden: + +```text +Revit: +Leistung = 500 W + +Leistungsbilanz: +Nutzer überschreibt auf 750 W + +nächster Revit-Sync: +Revit liefert wieder 500 W +``` + +Der bewusste Benutzerwert darf nicht kommentarlos verloren gehen. + +--- + +# 16. Rohdaten und normalisierte Daten + +Es sollte geprüft werden, ob zusätzlich zu den normalisierten Daten ein Teil des ursprünglichen Revit-Payloads gespeichert werden sollte. + +Beispiel: + +```text +rawData / rawPayload +``` + +Vorteile: + +- Debugging, +- spätere Erweiterung ohne sofortigen Revit-Neuexport, +- Nachvollziehbarkeit, +- Erkennung neuer Parameter. + +Dabei soll aber vermieden werden, die eigentliche Business-Logik auf unstrukturiertem JSON aufzubauen. + +--- + +# 17. CSV und JSON-Datei weiterhin unterstützen + +Auch bei einer direkten API-Anbindung sollen Datei-basierte Wege sinnvoll bleiben. + +Mögliche Architektur: + +```text + ┌─────────────────────┐ + │ Leistungsbilanz │ + │ RevitSyncService │ + └─────────┬───────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ + REST Datei + │ │ + ▼ ▼ + Dynamo Python JSON / CSV + │ + ▼ + Revit +``` + +Der gleiche interne Import-Service sollte möglichst sowohl: + +```text +HTTP JSON +``` + +als auch: + +```text +JSON-Datei +``` + +verarbeiten können. + +CSV kann als separates allgemeines Importformat bestehen bleiben. + +--- + +# 18. Kein direkter Datenbankzugriff aus Dynamo + +Dynamo soll nicht direkt auf SQLite, PostgreSQL oder andere Datenbanken der Leistungsbilanz zugreifen. + +Nicht empfohlen: + +```text +Dynamo + ↓ +direkte SQL-Verbindung + ↓ +DB +``` + +Empfohlen: + +```text +Dynamo + ↓ +API + ↓ +Business Logic + ↓ +ORM + ↓ +DB +``` + +Dadurch bleibt der Revit-Connector unabhängig von: + +- Tabellenstruktur, +- ORM, +- Migrationen, +- internen Relationen, +- späteren Änderungen des Backends. + +--- + +# 19. Zunächst kein WebSocket / Echtzeit-Sync + +Für den aktuellen Anwendungsfall ist kein permanenter Echtzeitkanal erforderlich. + +Nicht notwendig: + +```text +Leistungsbilanz wird geändert + ↓ +sofort automatische Änderung in Revit +``` + +Stattdessen soll zunächst mit expliziten Sync-Aktionen gearbeitet werden. + +Das ist auch im Hinblick auf Revit sinnvoll, da Änderungen über die Revit API kontrolliert und innerhalb gültiger Revit-Transactions vorgenommen werden müssen. + +--- + +# 20. Fehlerbehandlung + +Die Integration muss mit Teilfehlern umgehen können. + +Beispiele: + +- ein Element besitzt keinen MEP Space, +- IFC GUID fehlt, +- ein erwarteter Parameter fehlt, +- ein Revit-Parameter ist schreibgeschützt, +- HTTP-Verbindung schlägt fehl, +- API antwortet mit Validierungsfehler, +- Element wurde zwischen Abfrage und Writeback gelöscht, +- Revit-Modell ist noch nicht mit einem Leistungsbilanz-Projekt verknüpft. + +Das Dynamo-Skript soll möglichst nicht beim ersten fehlerhaften Element komplett abbrechen. + +Stattdessen sollte die Antwort z. B. unterscheiden: + +```text +success +warnings +errors +``` + +und am Ende eine Zusammenfassung ausgeben. + +--- + +# 21. Authentifizierung + +Codex soll prüfen, welche Authentifizierung die aktuelle Leistungsbilanz-App bereits verwendet. + +Der Dynamo-Connector soll keine unnötige zweite Authentifizierungsarchitektur einführen. + +Mögliche Varianten: + +- API Token, +- projektbezogenes Integration Token, +- bestehender Login-Mechanismus, +- kurzfristige Connector-Tokens. + +Secrets sollen nicht fest im Dynamo-Python-Code hinterlegt werden. + +--- + +# 22. Versionierung + +Das JSON-Schema und die API sollen versioniert sein. + +Beispielsweise: + +```text +schemaVersion = 1 +``` + +und/oder: + +```text +/api/integrations/revit/v1/ +``` + +Dadurch kann ein älteres Dynamo-Skript später noch mit einer neueren App-Version umgehen oder zumindest kontrolliert abgelehnt werden. + +--- + +# 23. Langfristige Perspektive: Revit Add-in + +Dynamo + Python ist für die erste Implementierung ideal, weil schnell getestet und angepasst werden kann. + +Langfristig könnte daraus ein echtes Revit-Add-in in C#/.NET werden. + +Beispiel: + +```text +Revit Ribbon + +┌──────── Leistungsbilanz ─────────┐ +│ │ +│ ↑ Daten übertragen │ +│ ↓ Daten aus Leistungsbilanz │ +│ │ +│ Projekt verbinden │ +│ Sync-Status │ +│ │ +└──────────────────────────────────┘ +``` + +Wichtig ist deshalb, dass die API nicht speziell auf Dynamo zugeschnitten wird. + +Ziel: + +```text +heute: +Dynamo + Python + ↓ +gleiche REST API + +später: +Revit Add-in C# + ↓ +gleiche REST API +``` + +--- + +# 24. Aktuell gewünschter Dynamo-Umfang + +Für den ersten produktiven Revit-Export soll Codex ein Dynamo-Python-Skript vorschlagen, das mindestens folgende Daten aus `OST_ElectricalFixtures` ermittelt: + +```text +revitModelId +ElementId +UniqueId +IFC GUID +Kategorie +Familie +Typ +MEP Raumnummer +MEP Raumname +``` + +Zusätzlich soll Codex anhand der aktuellen Leistungsbilanz-Datenstruktur prüfen, welche weiteren Felder bereits jetzt sinnvoll mitgegeben werden sollten. + +Dazu könnten später gehören: + +```text +Ebene +BMK +Leistung +cosPhi +Spannung +Anzahl +Kommentar +Stromkreis +Verteiler +weitere Shared Parameters +``` + +Diese zusätzlichen Felder sollen aber nicht willkürlich eingebaut werden, sondern aus der bestehenden Architektur und den fachlichen Anforderungen der Leistungsbilanz abgeleitet werden. + +--- + +# 25. Anforderungen an das Dynamo-Python-Skript + +Das Skript soll möglichst: + +- ohne externe Dynamo-Packages funktionieren, +- nur Revit API / Dynamo-eigene Python-Möglichkeiten verwenden, +- mit der im Projekt eingesetzten Revit-/Dynamo-Version kompatibel sein, +- alle Elektroinstallationen im gesamten aktuellen Revit-Dokument lesen, +- nicht nur die aktive Ansicht betrachten, +- fehlende optionale Parameter tolerieren, +- verständliche Fehlermeldungen liefern, +- eine gut lesbare Statusausgabe an `OUT` zurückgeben, +- JSON mit UTF-8 erzeugen, +- HTTP-Requests ohne unnötige externe Python-Abhängigkeiten ausführen, +- Timeouts behandeln, +- API-Fehler auswerten, +- keinen Datenverlust verursachen. + +Für spätere Writebacks muss das Skript Revit-Änderungen ausschließlich innerhalb gültiger Revit-Transactions durchführen. + +--- + +# 26. Gewünschter Arbeitsauftrag an Codex + +## Schritt 1 – Repository analysieren + +Vor Änderungen zuerst: + +1. `AGENTS.md` lesen. +2. Bestehende Architektur-Dokumentation lesen. +3. Datenmodell der Leistungsbilanz nachvollziehen. +4. Aktuelle Import-/CSV-Architektur untersuchen. +5. Vorhandene API-Routen analysieren. +6. Prüfen, ob bereits Entitäten oder Konzepte für: + - externe Modelle, + - Revit-Elemente, + - Linked Devices, + - Sync, + - Imports, + - Projekt-Historie + existieren. +7. Authentifizierung und Berechtigungssystem prüfen. +8. Bestehende Tests und Testkonventionen prüfen. + +Noch keine größere Implementierung beginnen, bevor diese Analyse abgeschlossen ist. + +--- + +## Schritt 2 – Bestehende Architektur gegen dieses Konzept abgleichen + +Codex soll beantworten: + +1. Welche vorhandenen Entitäten können wiederverwendet werden? +2. Brauchen wir überhaupt eine neue `RevitElement`-Entität? +3. Wie passt die Revit-Referenz in das bestehende `LinkedDevice`-/DeviceRow-Konzept? +4. Wo sollte das Sync-Verhalten im Backend liegen? +5. Wie soll die Modellidentität gespeichert werden? +6. Welche Felder sind Revit-Master? +7. Welche Felder sind Leistungsbilanz-Master? +8. Wie werden manuell überschriebene Werte geschützt? +9. Wie werden nicht mehr vorhandene Revit-Elemente behandelt? +10. Wie lässt sich das bestehende CSV-System sinnvoll weiterverwenden? + +--- + +## Schritt 3 – Konkreten Zielentwurf erstellen + +Codex soll danach einen konkreten Entwurf liefern für: + +### Datenmodell + +Mit tatsächlichen Namen passend zum Repository. + +### API + +Mit: + +```text +HTTP Method +Route +Request DTO +Response DTO +Validierung +Fehlercodes +Authentifizierung +``` + +### Sync-Service + +Mit klarer Verantwortung für: + +```text +Create +Update +Unchanged +Missing +Deleted +Linking +Conflict handling +``` + +### Dynamo-Schnittstelle + +Mit dem exakten JSON, das Dynamo senden bzw. empfangen soll. + +--- + +## Schritt 4 – Dynamo-Skripte vorschlagen + +Erst nachdem die Zielarchitektur feststeht, soll Codex konkrete Dynamo-Python-Skripte erstellen. + +Mindestens: + +### Script A – Revit → Leistungsbilanz + +Funktionen: + +```text +Revit-Modell-ID ermitteln +Electrical Fixtures sammeln +Daten auslesen +Payload erzeugen +JSON serialisieren +API aufrufen +Antwort auswerten +Status in Dynamo ausgeben +``` + +### Script B – Leistungsbilanz → Revit + +Noch nicht zwingend sofort implementieren, aber vollständig konzipieren. + +Funktionen: + +```text +Writeback-Daten von API abrufen +Element über UniqueId finden +Allowlist-Mapping anwenden +Werte prüfen +Revit Transaction starten +Parameter schreiben +Ergebnis an API melden +Status ausgeben +``` + +### Optional Script C – Debug-/Offline-Export + +Export des exakt gleichen Payloads als lokale JSON-Datei. + +Damit kann die Schnittstelle auch ohne erreichbare API getestet werden. + +--- + +# 27. Erwartete Ausgabe von Codex + +Codex soll nach der Analyse **nicht sofort große Änderungen durchführen**, sondern zuerst eine strukturierte Empfehlung liefern. + +Gewünschte Reihenfolge: + +```text +1. Analyse des Ist-Zustands +2. Abweichungen / bereits vorhandene passende Architektur +3. Empfohlenes Zieldesign +4. Datenmodelländerungen +5. API-Design +6. Sync-Logik +7. Exaktes JSON-Schema +8. Vorschlag Dynamo Script A +9. Vorschlag Dynamo Script B +10. Migrations-/Implementierungsreihenfolge +11. Tests +12. Offene Entscheidungen +``` + +Bei Architekturentscheidungen soll Codex die einfachste robuste Lösung bevorzugen und unnötige neue Abstraktionen vermeiden. + +--- + +# 28. Bevorzugte Implementierungsreihenfolge + +Voraussichtlich sinnvoll: + +```text +Phase 1 +──────────────────────────── +Austauschformat / DTO festlegen + +Phase 2 +──────────────────────────── +Backend API für Revit-Snapshot + +Phase 3 +──────────────────────────── +Persistierung und Upsert-Logik + +Phase 4 +──────────────────────────── +Dynamo Revit → API + +Phase 5 +──────────────────────────── +UI für importierte / nicht zugeordnete Revit-Elemente + +Phase 6 +──────────────────────────── +Zuordnungs- und Override-Verhalten + +Phase 7 +──────────────────────────── +Writeback API + +Phase 8 +──────────────────────────── +Dynamo API → Revit + +Phase 9 +──────────────────────────── +ggf. später Revit C# Add-in +``` + +--- + +# 29. Zentrale Architekturentscheidung + +Die wichtigste Vorgabe lautet: + +> Dynamo soll als Revit-Adapter dienen. Die eigentliche Fachlogik der Leistungsbilanz bleibt in der Leistungsbilanz-App. + +Dynamo soll deshalb nicht entscheiden: + +```text +welche Sicherung gewählt wird +welche Leitung verwendet wird +wie Stromkreise aufgebaut werden +wie Geräte gruppiert werden +welche Leistungswerte fachlich gültig sind +``` + +Dynamo liefert primär Revit-Daten und schreibt später explizit freigegebene Ergebnisse zurück. + +Die fachliche Entscheidung bleibt in der Webapp. + +--- + +# 30. Zielbild + +```text + REVIT + │ + Revit API + │ + Dynamo / Python + │ + JSON DTO + │ + REST / HTTP + │ + ▼ + Leistungsbilanz API + │ + Sync Service + │ + ┌───────────┴───────────┐ + │ │ + Revit-Referenz Business Logic + │ │ + └───────────┬───────────┘ + │ + DeviceRow + │ + Circuit + │ + Distributor +``` + +Später: + +```text +Leistungsbilanz + │ + Writeback + │ + ▼ +Dynamo / Python + │ + ▼ + Revit +``` + +Das Ziel ist keine möglichst komplexe Echtzeitkopplung, sondern eine robuste, nachvollziehbare und versionierbare Integration, die zunächst über Dynamo/Python funktioniert und später bei Bedarf durch ein natives Revit-Add-in ersetzt werden kann. diff --git a/docs/building-structure.md b/docs/building-structure.md new file mode 100644 index 0000000..db7f062 --- /dev/null +++ b/docs/building-structure.md @@ -0,0 +1,83 @@ +# Gebäudestruktur (Gebäude → Etagen → Räume) + +Projekte mit mehreren Gebäuden werden über eine eigene Ebene **Gebäude** +gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen +hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung +ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es +nicht. Abgestimmt in Issue #6: Das Modell ist so bestätigt, und Gebäude mit +zugeordneten Etagen lassen sich nicht löschen. + +## Datenmodell + +| Tabelle | Feld | Bedeutung | +|---|---|---| +| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts | +| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude | + +Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und +erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration +und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration +auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate +Datenbanksicherung anzulegen. + +## Befehle, Undo/Redo + +Alle Änderungen laufen über die bestehende Orts-Struktur +(`ProjectLocationStructureProjectCommandRepository`) mit Revision, Inverse und +persistentem Undo/Redo: + +- `project-building.insert` / `project-building.delete` mit vollständigem + Gebäude-Snapshot (Schema-Version 1) +- `project-building.update`: ändert nur den Namen +- Löschen eines Gebäudes wird abgelehnt, solange Etagen zugeordnet sind + („Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.“) +- Etagenbefehle `project-floor.insert/.update/.delete` haben seit + Schema-Version **2** das Feld `buildingId`. `project-floor.update` darf Name + und/oder Gebäude ändern, nicht aber `sortOrder`. Ein referenziertes Gebäude + muss zum Projekt gehören. +- Version-1-Etagenbefehle aus bestehender Historie bleiben ausführbar. Sie + stammen aus der Zeit vor den Gebäuden und werden vor der Ausführung mit + `buildingId = null` auf Version 2 gehoben; auch die daraus neu gespeicherten + Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie + exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im + Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen. +- Raumbefehle bleiben unverändert (Version 1). + +## Snapshots und Transfer + +Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`. +Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer +Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte +bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum +Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht +und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue +Gebäude-IDs und stellt die Etagenverweise um. + +## API + +- `GET /api/projects/:projectId/buildings` – Gebäude sortiert nach + `sortOrder`, Name +- `POST /api/projects/:projectId/buildings` – `{ "name": "Haus A", "expectedRevision": 3 }` +- `PUT /api/projects/:projectId/buildings/:buildingId` – `{ "name": "Haus B", "expectedRevision": 4 }` +- `DELETE /api/projects/:projectId/buildings/:buildingId` – `{ "expectedRevision": 5 }` +- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich + optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten + das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert. + +## Oberfläche + +Gebäude, Etagen und Räume werden auf der eigenen Seite +`/projects/:projectId/struktur` („Gebäudestruktur“) gepflegt, erreichbar über +die Seitenleiste und die Kennzahlen der Projektübersicht. Drei Spalten +Gebäude → Etagen → Räume filtern sich gegenseitig: Klick auf ein Gebäude zeigt +nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Filter +auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der +Stromkreisliste. + +## Offen + +- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die + Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die + Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg. +- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue + Gebäude werden hinten angehängt. diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md index 374994e..cd8b08d 100644 --- a/docs/cable-sizing-module.md +++ b/docs/cable-sizing-module.md @@ -1,159 +1,109 @@ -# Leitungsberechnung (Cable-Sizing-Modul) +# Leitungsberechnung -Optionales, abgegrenztes Rechenmodul für die Querschnittsempfehlung eines -Stromkreises. Es wurde als eigenständiges Modul vereinbart, das an die -Stromkreisliste angedockt ist. Es **schreibt keine Projektdaten selbst**: Ein -Ergebnis wird erst durch ausdrückliches „Übernehmen“ über den bestehenden -Befehl `circuit.update` in den Stromkreis geschrieben. Revisionen und -Undo/Redo bleiben damit unverändert gültig. +Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare +Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die +ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`, +`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`. +Revisionen sowie Undo/Redo bleiben dadurch erhalten. -Die vollständige, automatische Bemessung aller Stromkreise (siehe -`docs/spec/06-future-sizing-and-calculations.md`) ist weiterhin **nicht** -Teil dieses Moduls. +Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil +des Moduls. -## Aufbau und Andockpunkte +## Aufbau -``` -src/cable-sizing/domain/cable-sizing-calculation.ts reine Berechnung + Tabellen -src/cable-sizing/domain/cable-sizing-contracts.ts Zod-Schema der API +```text +src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen +src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte -src/server/routes/cable-sizing.routes.ts Router unter /api/cable-sizing +src/server/routes/cable-sizing.routes.ts Router src/frontend/components/cable-sizing-api.ts Fetch-Client -src/frontend/components/cable-sizing-defaults.ts Vorbelegung aus dem Stromkreis +src/frontend/components/cable-sizing-defaults.ts Vorbelegung src/frontend/components/cable-sizing-modal.tsx Dialog -tests/cable-sizing-calculation.test.ts Tabellen, Rechnung, Vorbelegung +tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle ``` -Das Domänenmodul importiert nichts aus `db/`, `server/` oder `frontend/`. -Außerhalb dieser Dateien gibt es genau zwei Andockpunkte: +Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist +zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die +Kabelzellen behalten ihr übliches Spreadsheet-Verhalten. -1. `src/server/index.ts`: `app.use("/api/cable-sizing", cableSizingRouter)`. -2. `src/frontend/components/circuit-tree-editor.tsx`: Zustand für den - geöffneten Dialog, ein Button **Leitungsberechnung** (Rechner-Icon) in der - Aktionsspalte jeder Stromkreiszeile, der Dialog selbst und - `handleApplyCableSizing`, das über `updateCircuitById` den regulären - `circuit.update`-Befehl ausführt. +## Eingaben -Der Aufruf liegt bewusst **nicht** auf einer Tabellenzelle: Klick, Doppelklick, -Enter, F2 und Tippen in den Kabelspalten verhalten sich wie in jeder anderen -Zelle (Spreadsheet-Regel in `AGENTS.md`). Es gibt keine eigene Datenbanktabelle -und keine Migration. +Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes +cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der +Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung, +zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen. -## Eingaben und Vorbelegung +Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf +hin, dass dann kein Spannungsfall angesetzt wird. -| Eingabe | Vorbelegung | -|---|---| -| Leistung | `circuitTotalPower` des Stromkreises (mit Geräte-GZF), nicht editierbar | -| Phasen | 3~, wenn die Stromkreisgruppe 3-phasig ist oder eine Gerätezeile `three_phase` hat, sonst 1~ | -| Spannung | `circuit.voltage` (aus den Projekteinstellungen abgeleitet); bei manuell abweichender Phasenzahl die jeweilige Projektspannung | -| cos φ | leistungsgewichtet aus den Gerätezeilen: P / Σ(P_Zeile / cos φ_Zeile), fehlendes cos φ = 1; änderbar | -| Länge | `cableLength` des Stromkreises | -| Schutzorgan | Schutzgerät des Stromkreises: LS, FI/LS, AFDD = Leitungsschutzschalter; D0/NH = Sicherung gG; FI ohne Überstromauslöser wird nicht berücksichtigt | -| Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung, max. ΔU, Oberschwingungen | Planereingaben | +## Normative Grundlage und Grenzen -Kabeltyp und Querschnitt lassen sich im Dialog auch ohne Berechnung direkt -eintragen. Weicht der Eintrag von der Empfehlung oder dem -Praxis-Mindestquerschnitt ab, erscheint nur ein Hinweis. +Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen +**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich +**Berichtigung 1:2023-10** geprüft: + +- Strombelastbarkeit: Tabellen 3 bis 6. +- Temperaturfaktoren: Tabellen 17 und 20. +- Häufung A1 bis C: Tabelle 22 nach der korrigierten Zuordnung. +- Häufung D: Tabelle 26 für sich berührende Rohre oder Kanäle im Erdreich. +- Oberschwingungen: Anhang B, insbesondere Tabelle B.1. + +Unterstützt werden A1, A2, B1, B2, C, D und E. D bezeichnet ausschließlich +mehradrige Kabel oder Mantelleitungen im Rohr beziehungsweise Kanal im +Erdreich. Direkte Erdverlegung wird nicht angeboten, weil DIN VDE 0298-4 dafür +auf die Kabel-Produktnorm DIN VDE 0276-603 verweist. + +Für Verlegeart E hängen Häufungsfaktoren von Trägerart, Abstand und Anzahl der +Kabelpritschen ab. Diese Angaben gehören noch nicht zum Eingabemodell. Daher +ist E nur für einen einzelnen Stromkreis berechenbar. Für D sind höchstens +sechs gehäufte Stromkreise durch die verwendete Tabellenzeile abgedeckt. + +Aluminium wird erst ab 25 mm² angeboten, weil die verwendeten Tabellen keine +kleineren Aluminiumquerschnitte enthalten. Bei mehr als 33 % dritter +Harmonischer ist der Neutralleiterstrom Bemessungsgrundlage. Solange dieser +nicht als Zahlenwert eingegeben und berechnet wird, liefert das Modul keine +Empfehlung. + +Die Berechnung des Spannungsfalls ist eine vereinfachte Ergänzung mit +Leitfähigkeit bei Betriebstemperatur; Reaktanz wird vernachlässigt. Ihre +Planungsgrenzen folgen nicht allein aus DIN VDE 0298-4. Auch die Koordination +mit Leitungsschutzschaltern und gG-Sicherungen gehört zur +Überstromschutzbemessung und ist getrennt von der Strombelastbarkeit zu +beurteilen. Der Dialog bezeichnet diese Ergebnisse deshalb nicht als vollständig +normative Leitungsbemessung. ## Rechenweg -1. Betriebsstrom I_B aus Leistung, Spannung, cos φ und Phasenzahl. -2. Erforderliche Belastbarkeit: ohne Schutzorgan I_B; mit Schutzorgan - max(I_B, I_n, I₂/1,45) nach IEC 60364-4-43 (I_n ≤ I_z und I₂ ≤ 1,45 · I_z). - I₂/I_n: Leitungsschutzschalter 1,45; Sicherung gG 2,1 (I_n ≤ 4 A), - 1,9 (4 A < I_n < 16 A), 1,6 (I_n ≥ 16 A). -3. Tabellenwert I_z je Querschnitt × Temperaturfaktor × Häufungsfaktor - (× 0,86 bei 15–33 % 3. Harmonischer im Drehstromkreis). -4. Spannungsfall ΔU = k · L · I_B · cos φ / (κ · A · U) mit k = 2 (1~) bzw. - √3 (3~), κ = 48 (Cu) bzw. 30 (Al) m/(Ω·mm²) bei Betriebstemperatur; - Reaktanz wird vernachlässigt. -5. Empfehlung = größerer Querschnitt aus Belastbarkeit und Spannungsfall; - 1-phasige Stromkreise werden auf den Praxis-Mindestquerschnitt 2,5 mm² - angehoben (Planungskonvention aus `docs/spec/06-…`, keine Rechenanforderung). -6. Zusätzlich: maximale Länge für den zulässigen Spannungsfall. +1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl. +2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen + Schutzorgan. +3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls + Oberschwingungsfaktor. +4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt. +5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall. +6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen + Mindestquerschnitt von 2,5 mm². -Temperaturen und Häufungen **zwischen** Tabellenwerten werden auf den nächsten -ungünstigeren Tabellenwert gerundet (höhere Temperatur, mehr Stromkreise). -Werte **außerhalb** der Tabellen liefern kein Ergebnis und eine Meldung – es -wird nie stillschweigend mit Faktor 1 gerechnet. - -## Datenbasis und Prüfnachweis - -Grundlage ist **IEC 60364-5-52:2009, Anhang B**. Alle Werte wurden am -26.09.2026 zeilenweise mit öffentlich zugänglichen Wiedergaben abgeglichen: -TiSoft ElectricalDesign-Hilfe zu IEC 60364-5-52 -(`ti-soft.com/en/support/help/electricaldesign/standards/iec-60364-5-52/…`) -und ECalPro (`ecalpro.com/standards/iec-60364/…`). Wo die Quellen voneinander -abweichen, ist der kleinere, sichere Wert eingetragen. - -| Tabelle | Inhalt | Abgleich | Abweichungen | -|---|---|---|---| -| B.52.2 | PVC, 2 belastete Adern, Cu/Al, A1–D2 | TiSoft; Cu A1–C zusätzlich ECalPro | Cu A1 240 mm²: 321 / 320 → 320 | -| B.52.3 | VPE, 2 belastete Adern, Cu/Al, A1–D2 | TiSoft | – | -| B.52.4 | PVC, 3 belastete Adern, Cu/Al, A1–D2 | TiSoft | – | -| B.52.5 | VPE, 3 belastete Adern, Cu/Al, A1–D2 | TiSoft | – | -| B.52.10 / .11 | PVC, Verlegeart E, mehradrig, Cu / Al | TiSoft; Cu 2 Adern zusätzlich ECalPro | Cu 3 Adern 70 / 120 mm²: 198 / 278 bzw. 196 / 276 → 196 / 276 | -| B.52.12 / .13 | VPE, Verlegeart E, mehradrig, Cu / Al | TiSoft | – | -| B.52.14 | Temperatur Luft, PVC / VPE | TiSoft, ECalPro | – | -| B.52.15 | Temperatur Erde, PVC / VPE | TiSoft | – | -| B.52.17 | Häufung Luft, Zeile 1 (gebündelt) | TiSoft, ECalPro | – | -| B.52.18 | Häufung Erde direkt, Kabel berührend | TiSoft | – | -| B.52.19 A | Häufung mehradrige Kabel in berührenden Rohren im Erdreich | TiSoft | – | - -Anwendung der Tabellen: - -- Verlegearten A1, A2, B1, B2, C, E in Luft (Bezug 30 °C), D1 und D2 im - Erdreich (Bezug 20 °C, Bodenwärmewiderstand 2,5 K·m/W). Nach Tabelle B.52.1 - ist **D1 = Leitung im Rohr/Kabelschutzrohr im Erdreich** und **D2 = Kabel - direkt im Erdreich**. -- Häufung in Luft immer nach B.52.17 Zeile 1, auch für Verlegeart E; das ist - die ungünstigste Anordnung (einlagig auf Pritschen wären höhere Faktoren - zulässig). D1 nach B.52.19 A, D2 nach B.52.18 jeweils „berührend“. -- Aluminium nur mit den echten Aluminiumwerten (ab 2,5 mm², D2 ab 16 mm²); - Querschnitte ohne Tabellenwert werden nie empfohlen. -- Die Verlegearten F und G (Einzeladern mit Abstand) sind nicht enthalten. - -**Vor produktivem Einsatz:** Die nationalen Tabellen der DIN VDE 0298-4 -können in einzelnen Werten abweichen. Der Planer muss die Tabellen gegen den -Normtext prüfen. Die Prüfung ist in `tests/cable-sizing-calculation.test.ts` -über Stichwerte und eine Monotonieprüfung jeder Tabellenzeile abgesichert. +Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten +ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs +wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin +manuell eingetragen und bewusst übernommen werden. ## Nicht enthalten -- Kurzschlussfestigkeit, Abschaltbedingungen (Schleifenimpedanz) und - Selektivität -- Bodenwärmewiderstand ≠ 2,5 K·m/W, Häufung mit Abstand, Verlegearten F/G -- Bemessung des Neutralleiters bei > 33 % 3. Harmonischer (nur Hinweis) -- automatische Bemessung oder Prüfung aller Stromkreise +- direkte Erdverlegung +- detaillierte Anordnung mehrerer Kabel bei Verlegeart E +- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer +- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität +- abweichender Bodenwärmewiderstand sowie Verlegearten F und G +- automatische Berechnung oder Änderung mehrerer Stromkreise ## API -`POST /api/cable-sizing/calculate` – zustandslos, liest und schreibt keine -Projektdaten. +`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist +`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält +`supported: false` und einen der Gründe `temperature`, `grouping` oder +`harmonics`, wenn keine belastbare Empfehlung möglich ist. -```jsonc -{ - "phase": 1, // 1 | 3 - "mode": "power", // "power" | "current" - "powerKw": 4.2, // bei mode "power" - "currentA": 18.3, // bei mode "current" - "cosPhi": 0.95, // 0,1 … 1 - "voltage": 230, - "lengthM": 23.5, - "layingMethod": "C", // A1 | A2 | B1 | B2 | C | D1 | D2 | E - "conductorMaterial": "copper", // copper | aluminum - "insulation": "pvc", // pvc | xlpe - "ambientTemperatureC": 30, // Luft; bei D1/D2 Bodentemperatur - "groupingCircuits": 1, // 1 … 100, Tabellen bis 20 - "maxVoltageDropPercent": 3, - "harmonicNeutralLoad": "none", // none | 15to33Percent | over33Percent - "existingProtection": { "ratedCurrentA": 16, "kind": "breaker" }, // optional; kind: breaker | fuse - "circuitCategory": "single_phase" // optional -} -``` - -Antwort: `{ "result": CableSizingResult, "alerts": CableSizingAlert[] }`. -`result.supported = false` mit `unsupportedReason` (`temperature` oder -`grouping`), wenn die Eingaben außerhalb der Tabellen liegen. -`GET /api/cable-sizing/laying-methods` und `GET /api/cable-sizing/insulation-materials` -liefern die Auswahllisten. +`GET /api/cable-sizing/laying-methods` und +`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 0b24cc0..e528f6e 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -59,6 +59,14 @@ this value for optimistic concurrency checks. - clients cannot submit arbitrary `project.restore-state` payloads through the generic command endpoint +Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable +Projektexport unter `GET /projects/:projectId/export` verwendet dagegen +Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege +`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre +Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo, +Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen +importierbar. + The public dispatcher supports the Circuit and CircuitDeviceRow commands `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, `circuit-device-row.insert`, @@ -74,8 +82,9 @@ Protected-board structure uses persisted inverse of a delete. The dispatcher also supports the documented project-device, project settings, distribution-board and project-location commands. The generic editor commands listed here use schema version `1`. -`project.update-settings` uses version `2` and still accepts supported baseline -version `1` commands without changing the newer building classification. +`project.update-settings` uses version `3` (including the print cover fields) +and still accepts version `2` and supported baseline version `1` commands +without changing the newer cover fields or building classification. Pre-baseline formats remain unsupported. Unsupported types or schema versions are rejected. Insert commands contain the complete entity or circuit block with stable ids; delete commands include the expected parent identity. Circuit @@ -173,6 +182,9 @@ synchronizes linked circuit rows implicitly. - `PUT /projects/:projectId` - request: `{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }` + - optional print cover fields `siteAddress` (max. 500), `workStage` (max. + 100), `projectLead` and `projectEditor` (max. 200 each); omitted fields keep + their value, empty strings are stored as `null` - executes `project.update-settings` as one atomic revision - response: `{ "project": { ... }, "revision": { ... }, "history": { ... } }` @@ -210,6 +222,12 @@ A stale `expectedRevision` returns HTTP `409` with `PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. +## Estimate Balance (Grobe Leistungsbilanz) + +Separate module under `/projects/:projectId/estimate-balances`; complete +documents are written through `estimate-balance.insert/.update/.delete` project +commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. + ## Circuit-First Endpoints ### Distribution Board Setup @@ -230,9 +248,13 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. - stale revisions return `409 PROJECT_REVISION_CONFLICT` - `PUT /projects/:projectId/distribution-boards/:distributionBoardId` - body: - `{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }` - - executes `distribution-board.update`; floor and supply type are restored - together by persistent Undo/Redo + `{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }` + - optional info-card fields: `boardNumber` (max. 50), `location`, + `fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed + and an empty string is stored as `null` + - executes `distribution-board.update`; all submitted values, including the + submitted info fields, are restored together by persistent Undo/Redo + - `fedFrom` is free text until the logical feed link is modeled - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - duplicates the complete distribution-board subtree with new owning UUIDs @@ -249,15 +271,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. `distribution-board.delete-subtree`. Persistent Undo/Redo restores or removes the same complete subtree, including protection devices. -### Project Floors and Rooms +### Project Buildings, Floors and Rooms +- `GET /projects/:projectId/buildings` +- `POST /projects/:projectId/buildings` + - body: `{ "name": "Haus A", "expectedRevision": 12 }` + - executes `project-building.insert` with a stable building id + - response: `{ "building": { ... }, "revision": { ... }, "history": { ... } }` +- `PUT /projects/:projectId/buildings/:buildingId` + - body: `{ "name": "Haus B", "expectedRevision": 13 }` (name only) +- `DELETE /projects/:projectId/buildings/:buildingId` + - body: `{ "expectedRevision": 14 }` + - rejects buildings with assigned floors - `POST /projects/:projectId/floors` - - body: `{ "name": "EG", "expectedRevision": 13 }` - - executes `project-floor.insert` with a stable floor id + - body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }` + (`buildingId` optional, `null` = no building) + - executes `project-floor.insert` (command version 2) with a stable floor id - response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` - `PUT /projects/:projectId/floors/:floorId` - - body: `{ "name": "1. OG", "expectedRevision": 14 }` - - executes an exact persistent floor update + - body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }` + - executes an exact persistent floor update of name and/or building; a + missing `buildingId` keeps the current building - `DELETE /projects/:projectId/floors/:floorId` - body: `{ "expectedRevision": 15 }` - rejects floors with assigned rooms or distribution boards @@ -399,14 +433,15 @@ read nor write project data; applying a result uses `circuit.update` through - `POST /cable-sizing/calculate` - request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi, - voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D1` | - `D2` | `E`), conductorMaterial, insulation (`pvc` | `xlpe`), + voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` | + `E`), conductorMaterial, insulation (`pvc` | `xlpe`), ambientTemperatureC, groupingCircuits, maxVoltageDropPercent, harmonicNeutralLoad, optional `existingProtection` (`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory - returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported` is `false` with `unsupportedReason` when temperature or grouping lie - outside the tables + outside the tables or the neutral current needed above 33 % third + harmonic is unavailable - unknown fields are rejected (`400`) - `GET /cable-sizing/laying-methods` - `GET /cable-sizing/insulation-materials` @@ -447,3 +482,27 @@ Full request example and calculation rules: `docs/cable-sizing-module.md`. Parser errors expose stable codes such as `header-not-found`, `ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed import uploads and parses the file again and verifies its hash. + +## Document Branding (print view) + +Office-wide branding for the print view. These endpoints are mounted at +`/api/document-branding`, outside `/api/projects`, because the data is global +and intentionally not part of project revisions, snapshots or transfer. + +- `GET /document-branding` + - returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null, "office": { "officeName", "officeAddress" } }` +- `GET /document-branding/logo` + - returns the stored image bytes with its media type, or `404` without logo + - responses carry `X-Content-Type-Options: nosniff` and a restrictive + Content-Security-Policy so an SVG logo cannot execute scripts +- `PUT /document-branding/logo` + - request: `{ "fileName": "logo.png", "mimeType": "image/png", "contentBase64": "..." }` + - accepted types: `image/png`, `image/jpeg`, `image/svg+xml`; at most 2 MB + after decoding; the content signature must match the declared type + - replaces an existing logo; returns the logo metadata +- `DELETE /document-branding/logo` + - removes the logo; returns `204` +- `PUT /document-branding/office` + - request: `{ "officeName": "…" | null, "officeAddress": "…" | null }` + (at most 200 / 500 characters; empty text is stored as not set) + - returns `{ "office": { "officeName", "officeAddress" } }` diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index c8c02aa..8050cfe 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -1,5 +1,8 @@ # Circuit List Editor Interactions +The back-arrow link beside the page title returns to the project's distribution +board overview without changing circuit-list data. + ## Editing Model Inline cells are static text by default. A cell enters edit mode by: @@ -44,6 +47,7 @@ Selection, keyboard movement, and editability checks run against this normalized - arrow keys: move selected cell in grid when not editing - type-to-edit: printable keys open edit mode and replace current display value with typed character - `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context +- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table - `Delete`: delete the selected circuit or device context after confirmation For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers. @@ -52,6 +56,23 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction. +## AMEV Simultaneity Factor + +The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open +the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project +device dialog uses as well. "Übernehmen" commits the mean value of the chosen +consumer group through the same path as a typed edit (`commitEdit` with an +explicit target cell), so it is one undoable command and works on device rows, +compact circuits, reserve circuits and the `-frei-` row. A running inline edit +of the cell is discarded when the picker opens. The button only appears on the +selected cell; the grid keeps its static-text cells. + +The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and +closes on Escape without writing a value. Closing returns focus to the grid; +only "Übernehmen" saves the selected mean value. Opening the picker is disabled +while a write or history operation is running. Its cell button stays outside +the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell. + ## Equipment Identifier Validation Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace. @@ -127,11 +148,30 @@ Cross-section device moves show confirmation-required feedback before drop. The ## Filtering and Sorting +- Clicking a column label changes sorting; the funnel icon next to it opens + the value filter. An active filter is highlighted and shows the number of + selected values. Both controls retain German accessible names and tooltips. +- Header labels wrap between words; trailing units such as `[kW]` appear on + a separate line. - Per-column filtering works on normalized displayed values. - Sorting is view-level first and treats circuits as blocks. - Multi-device circuits are not split during sort. - Sorting alone does not persist order. +## Action Buttons + +The editor toolbar uses icons with visible German labels. Group headers and +circuit/device rows use compact icon buttons with German accessible names and +tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish +their roles. Dividers separate related actions; destructive actions use the +danger style. Circuit deletion uses a trash icon, while device-row deletion +uses a crossed-out list icon. + +The shared `src/frontend/components/icon-button.tsx` component preserves +disabled states and expanded states for toggles. Row and group action buttons +retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains +unchanged. The existing command handlers and deletion confirmations are reused. + ## Apply Sorted Order `Apply sorted order` persists current sorted block order to backend by section. @@ -161,6 +201,11 @@ across multiple sections is atomic and occupies one history step. Explicit renumbering uses a collision-safe persistent project command and one project-history step. +The editor creates client-side UUIDs through `createClientId` in +`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available +and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion +also works over plain HTTP from another machine in the LAN. + Covered operations include: - insert/delete circuit diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 521a9dc..78bb44e 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -9,6 +9,8 @@ - Named and automatic logical project snapshots can be listed and restored through the project page with persistent Undo/Redo. Automatic snapshots are created every 25 revisions and only their newest 12 entries are retained. +- Further Revit work is deferred; it will later use the CSV export/import of + the Linear plugin in Revit, not Dynamo. - The Revit/CSV foundation and confirmed initial import are implemented. Existing-row assignment, move and detach plus creation of an external-only row in an existing or newly created circuit have atomic persistent commands. @@ -18,11 +20,11 @@ - The global device library supports basic CRUD and copy operations, but has no versioning, permissions or controlled synchronization model. - There is no automatic sizing of all circuits. The optional cable sizing - module suggests a cross-section for one circuit on demand (IEC 60364-5-52 - Annex B tables; no short-circuit, disconnection-time or selectivity checks, - no methods F/G, soil resistivity fixed at 2.5 K·m/W) and does not validate - manually entered cable values. National tables (DIN VDE 0298-4) must be - checked by the planner; see `docs/cable-sizing-module.md`. + module suggests a cross-section for one circuit on demand using + DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct + burial, grouped method E and neutral-current sizing above 33 % third + harmonic, and it does not check short-circuit withstand, disconnection time + or selectivity. See `docs/cable-sizing-module.md`. - Sorting is view-only until users explicitly apply sorted order. - Complete-circuit drag supports one circuit at a time between groups of the same category. Cross-category moves and multi-circuit moves across group @@ -31,3 +33,12 @@ - Protection and cable values are planner-owned manual selections. The later sizing/warning engine does not yet validate them against load, length or voltage-drop rules. +- Distribution-board `fedFrom` is free text. The print overview derives its + supply structure and "Σ inkl. unterlagert" only from exact text matches with + board numbers/names and adds values without an upstream simultaneity factor + (see `docs/print-view.md`). +- The print view uses a kbp corporate design derived from logo and + presentation material. Snapshots do not record an editor, so the cover's + Planstände list shows the project's current editor for every entry. On screen, long + board plans are not split into pages; pagination happens in the browser print + dialog. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 7e78fac..2887e8a 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -27,9 +27,18 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000 data/leistungsbilanz.db (SQLite) ``` -Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das -Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank -liegt über einen Host-Mount außerhalb des Containers. +In beiden Docker-Stacks laufen Frontend und API in getrennten Containern. +`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung +mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API +weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die +Datenbank liegt über einen Host-Mount außerhalb des Containers. + +Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt +der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die +Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls +`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen +auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen +vor dem Command und bleiben über Undo/Redo unverändert. ## Wichtige Einstiegspunkte @@ -232,8 +241,9 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. `project.update-settings` versioniert Projektname, interne und externe -Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches -Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten +Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift, +Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration +`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens @@ -245,22 +255,55 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen. -Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte -Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals -statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare, +Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre +versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter +Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite +„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können +einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare, fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, damit Änderungen weiterhin niemals still synchronisiert werden. -`GET /api/projects/:projectId/export` verpackt denselben vollständigen -Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument -mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, -Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace` -läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft -rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, +Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare +Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw. +Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die +Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der +Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor +`IconButton`/`IconLink`. +Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen +gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener +Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben +Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente. +Die Verteilersuche berücksichtigt Name, Verteilernummer, Örtlichkeit und +Etagenname; die Gerätesuche +Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart +und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig. +Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter +und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende +Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise +„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei +der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte. +Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar. +Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe +und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine +Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts +erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den +bestehenden persistenten Projektbefehl. + +`GET /api/projects/:projectId/export` verpackt den vollständigen portablen +Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit +SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen, +ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne +Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format, +Snapshot-Relationen, Grobbilanzen und Prüfsumme vor jedem Schreibzugriff. Der +Modus `replace` läuft über `project.restore-state` Version 2 und ersetzt den +Projektzustand und die Grobbilanzen atomar und dauerhaft rückgängig machbar. +Normale Snapshot-Restores verwenden weiterhin Version 1 und lassen +Grobbilanzen unverändert. Der Modus `duplicate` ordnet Projekt-, Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und -Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt -die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen +Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen +vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an. +Fachliche Verknüpfungen innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die Projektübersicht verwendet dafür den separaten Collection-Endpunkt @@ -416,7 +459,16 @@ wieder her. verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder +Derselbe Befehl versioniert die Felder der Verteiler-Infokarte +(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die +Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale +Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und +ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. +Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung +der Etagen; ältere Versionen werden ohne Gebäude hochgestuft. +Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des +Projekts; ältere Versionen werden mit `null` +hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und enthält bereits den vollständigen externen Modellzustand aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die @@ -455,8 +507,10 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät. ## Leitungsberechnung Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine -Querschnittsempfehlung für einen Stromkreis (IEC 60364-5-52 Anhang B: -Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan). Die +Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur +und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich +Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit +den in der Moduldokumentation beschriebenen Grenzen geprüft. Die Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button „Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp, @@ -498,7 +552,7 @@ Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen: `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und - Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente + Transfer-Integration verwenden aktuell Snapshot-Schema 6. Persistente Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur @@ -517,6 +571,10 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter. ## Revit-CSV-Grundlage +**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann +über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. +Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md). + Der reine Adapter unter `src/external-model/csv` validiert die projektweite Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt- @@ -526,7 +584,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl `external_csv_configurations` enthält höchstens eine versionierte Konfiguration je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder -Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5, +Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 6, Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren werden Konfigurations-UUID und Projektlink remapped. diff --git a/docs/deployment.md b/docs/deployment.md index 909c2c0..0b5baba 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -16,9 +16,10 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen Entwicklungsrechner. -Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch -ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich -erreichbar gemacht werden. +Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten +Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten +Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf +deshalb nicht öffentlich erreichbar gemacht werden. ## Topologie @@ -34,6 +35,11 @@ erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar. +Beim HTTP-Zugriff auf den Server-Stack aus dem LAN erzeugt der Editor neue +UUIDs über `createClientId`, mit `crypto.getRandomValues()` als Ersatz für das +dort nicht verfügbare `crypto.randomUUID()`. HTTPS ist für diese +ID-Erzeugung nicht erforderlich. + ### Speichergrenzen Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich @@ -140,14 +146,17 @@ Vor einer produktiven Installation werden mindestens benötigt: - Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für Mehrbenutzerbetrieb erforderlich ist -Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem -lokalen Entwicklungsstart in der README. +Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des +gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes +Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen. ## Backup und Wiederherstellung `npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup, öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese -Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt. +Dateisicherungen sind von den bereits vorhandenen benannten und automatischen +logischen Projektsicherungen getrennt. Projektsicherungen werden über die +Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo. Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei diff --git a/docs/development-workflow.md b/docs/development-workflow.md index b19b7ff..f630063 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -13,6 +13,11 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden `data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch angelegt. Das Frontend ist unter erreichbar. +Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung +mit Quellcode-Mounts und Hot Reload stattdessen +`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste +sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar. + Für direkte Node.js-Entwicklung: ```powershell @@ -86,7 +91,8 @@ Bei Docker- oder Laufzeitänderungen zusätzlich: ```powershell docker compose up --build --detach docker compose ps -Invoke-WebRequest http://localhost:3000/health +Invoke-WebRequest http://localhost:3001/health +Invoke-WebRequest http://localhost:3001/web-health ``` Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich. diff --git a/docs/estimate-balance.md b/docs/estimate-balance.md new file mode 100644 index 0000000..b8b675b --- /dev/null +++ b/docs/estimate-balance.md @@ -0,0 +1,119 @@ +# Grobe Leistungsbilanz (LPH 2/3) + +Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert +nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und +Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als +Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung +folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung: +Issue #7. + +Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die +Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt +nichts in Stromkreislisten, Verteilungen oder Projektgeräte. + +## Aufbau + +``` +src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports) +src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle +src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port +src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas +src/db/schema/estimate-balances.ts Tabellen (Migration 0010) +src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store +src/server/controllers/estimate-balance.controller.ts API +src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor +src/frontend/estimate-balance/ Editor-Komponenten und Helfer +``` + +Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil +das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf). + +## Datenmodell + +| Tabelle | Inhalt | +|---|---| +| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung | +| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung | +| `estimate_rows` | Versorgung (Auswahl ELT/MSR oder Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung | + +Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft, +434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch, +480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden +Stands mit neuen IDs. Stände bleiben bearbeitbar; ein zusätzlicher Status oder +eine Bearbeitungssperre ist derzeit nicht vorgesehen. + +## Rechnung + +- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ. +- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich + die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt + werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in + `calculateEstimateRow` und lässt sich dort zentral ändern. +- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt), + Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der + Drehstromspannung aus den Projekteinstellungen. +- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`, + 40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das + Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 % + Reserve 157,56 kW / 163,33 kVA, 235,75 A). + +## Vergleich geschätzt ↔ detailliert + +`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des +Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und +ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das +längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440. +Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der +Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1. +Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %. + +## Befehle, Undo/Redo + +Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit +Revision, Inverse und persistentem Undo/Redo: + +- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen + Stand (Gruppen und Zeilen) +- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der + Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und + ersetzt ihn atomar. Die Inverse ist das vertauschte Paar. + +Der Editor speichert das ganze Dokument verzögert (Autosave); jeder +Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine +Revision (`{ unchanged: true }`). + +## Projekttransfer und bewusste Abgrenzung + +- Das reguläre SQLite-Datenbank-Backup sichert die Grobbilanz-Tabellen zusammen + mit allen übrigen Anwendungsdaten. +- Benannte und automatische Projektsnapshots enthalten keine Grobbilanzen; das + Snapshot-Schema bleibt Version 7. Snapshot-Restore und dessen Undo/Redo lassen + vorhandene Grobbilanzen unverändert. +- Der portable Projektexport enthält alle Grobbilanzen ab Transferformat 2. + Ersetzen übernimmt sie atomar und rückgängig machbar; Duplizieren vergibt neue + IDs für Bilanz, Gruppen und Zeilen. Alte Transferdateien in Version 1 bleiben + importierbar und enthalten definitionsgemäß keine Grobbilanzen. +- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe. +- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet + mit Präfixen. +- Der erste Vergleich zeigt die geschätzte und detaillierte AV-Wirkleistung in + kW. SV- und kVA-Vergleiche können später ergänzt werden. +- Eine Stromkreisnummer gehört nicht zur Grobbilanz, weil sie eine frühe + Abschätzung ohne Stromkreisplanung ist. + +## API + +- `GET /api/projects/:projectId/estimate-balances` +- `POST /api/projects/:projectId/estimate-balances` – + `{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }` + (`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`) +- `GET /api/projects/:projectId/estimate-balances/:balanceId` +- `PUT /api/projects/:projectId/estimate-balances/:balanceId` – + `{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }` +- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }` +- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison` + +Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und +Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum +`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden +abgelehnt. diff --git a/docs/print-view.md b/docs/print-view.md new file mode 100644 index 0000000..eba91ed --- /dev/null +++ b/docs/print-view.md @@ -0,0 +1,127 @@ +# Seitenansicht und PDF-Export + +Die Seitenansicht ist rein lesend und verändert keine Projektdaten. +Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen. +Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen +über Projektbefehle mit Undo/Redo gepflegt. + +Beim Laden werden die Projektrevisionen aller Stromkreislisten und ein +abschließend erneut gelesener Projektstand verglichen. Ändert sich das Projekt +währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum +Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht +automatisch bei späteren Änderungen in einem anderen Tab. + +## Aufruf + +- Projektseite → Button **Seitenansicht** öffnet + `/projects/:projectId/print` in einem neuen Tab. +- **Als PDF speichern / Drucken** nutzt den Druckdialog des Browsers + (Ziel „Als PDF speichern“). Der Dokumenttitel `Leistungsbilanz – ` + wird als Dateiname vorgeschlagen. +- **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um. + Deckblatt bleibt A4 hoch. +- **Büroangaben** pflegt Büroname und Büroanschrift (Planverfasser, für alle + Projekte). +- **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo. + +## Seiten + +1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument, + Planstände (aus benannten Sicherungspunkten) und Inhalt. Leere Angaben + erscheinen als orangefarbenes „[ergänzen]“. +2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je + Verteiler mit Stromkreisanzahl, Pinst (Anzahl × Leistung je + Stück), PGeräte (mit Geräte-GZF), Verteiler-GZF, + Pgl, IB und „Σ inkl. unterlagert“. +3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit + Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und + Pgl; darunter Kopfkomponenten, Gruppen, Stromkreise + (mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen), + Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ + markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim + Drucken auf Folgeseiten. + +Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten +`@page`-Regeln (`lb-portrait`, `lb-landscape`), die die Seite zur Laufzeit +einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte +Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken. + +## Vorläufige Punkte + +- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet + einen Verteiler nur dann unter, wenn der Text eindeutig der Nummer oder + Bezeichnung eines anderen Verteilers entspricht (ohne Beachtung von + Groß-/Kleinschreibung und äußeren Leerzeichen). Mehrdeutige Treffer bleiben + ohne Zuordnung; „Σ inkl. unterlagert“ ist + eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung + (Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind + offen. +- **IB** ist ein symmetrischer Drehstromwert + S / (√3 · U3~) mit S aus P / cos φ je Gerätezeile + (fehlendes cos φ = 1) und Verteiler-GZF. +- **Corporate Design:** Farben (`--pd-*` in + `src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo + und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle + CD-Vorgabe ersetzt, falls diese abweicht. +- **Planstände:** Die Spalte „Bearbeitung“ zeigt für jeden Planstand die + aktuelle Bearbeitung aus den Projekteinstellungen, weil Sicherungspunkte + (noch) keinen Bearbeiter speichern. + +## Datenquellen des Deckblatts + +| Feld | Quelle | +|---|---| +| Bauvorhaben, Bauherr, Projektnummern, Beschreibung | Projekteinstellungen | +| Anschrift Bauvorhaben, Leistungsphase, Projektleitung, Bearbeitung | Projekteinstellungen → Abschnitt „Deckblatt“ (`siteAddress`, `workStage`, `projectLead`, `projectEditor`) | +| Büro, Anschrift Büro | Seitenansicht → „Büroangaben“ (global) | +| Stand, Datenstand, Anzahl Verteiler, Inhalt | automatisch | +| Planstände | benannte Sicherungspunkte | + +Die vier Projektfelder laufen über `project.update-settings` (Schema-Version +3, Migration `0008`) und sind Teil von Snapshot-Schema 6 und Projektexport. +Im `PUT /api/projects/:projectId` sind sie optional; fehlende Felder behalten +ihren Wert. + +## Büroangaben + +- Tabelle `document_branding_settings` (Schlüssel/Wert, Migration `0008`), + global wie das Logo. +- `GET /api/document-branding` liefert zusätzlich + `office: { officeName, officeAddress }`. +- `PUT /api/document-branding/office` mit `{ "officeName", "officeAddress" }` + (max. 200 / 500 Zeichen, leerer Text = nicht gesetzt). + +## Logo + +- Globale Büro-Einstellung, nicht Teil von Projekten, Revisionen, Snapshots + oder Projektexport. +- Tabelle `document_branding_assets` (Migration `0007`). +- `GET /api/document-branding` → `{ logo: { mimeType, fileName, sizeBytes, updatedAtIso } | null }` +- `GET /api/document-branding/logo` → Bilddaten (`404` ohne Logo), mit + `X-Content-Type-Options: nosniff` und restriktiver CSP. +- `PUT /api/document-branding/logo` mit + `{ "fileName", "mimeType": "image/png" | "image/jpeg" | "image/svg+xml", "contentBase64" }`, + maximal 2 MB; der Dateiinhalt wird gegen das Format geprüft. +- `DELETE /api/document-branding/logo` → `204`. + +## Code + +- `src/app/projects/[projectId]/print/page.tsx` – Datenladen, Werkzeugleiste, + `@page`-Regeln +- `src/frontend/print/print-document-model.ts` – reine Berechnungen und + Formatierung (Tests: `tests/print-document-model.test.ts`) +- `src/frontend/print/print-document.tsx` – Blätter und Tabellen +- `src/frontend/print/print-document.css` – Layout, auf `.print-view` + begrenzt +- `src/frontend/components/distribution-board-info-card.tsx` – Infokarte im + Bearbeiten-Dialog der Verteilung + +## Bestehende Datenbanken + +Vor dem Anwenden der additiven Migrationen `0007` und `0008` eine verifizierte +Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und +Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`). +Snapshot-Schema 6 enthält beide Feldgruppen; die unterstützten Versionen 1–5 +werden beim Lesen hochgestuft. Restore und Projektkopie erhalten die neuen +Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht +zum logischen Projektsnapshot oder Projekttransfer. diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 4c0a8ea..f8ed263 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -113,6 +113,10 @@ Implemented automatic-snapshot policy: ## Revit / CSV / IFCGUID Round-Trip Direction +**Deferred:** further Revit work will be tackled later, then through the +CSV export/import of the Linear plugin in Revit instead of Dynamo. Details: +[Revit-CSV requirements, section 1](spec/revit-csv-integration-requirements.md). + External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits. Conceptual entities: @@ -275,6 +279,10 @@ Completed foundation: - `project-floor.insert` and `project-room.insert` persist complete location records with stable ids; their inverses reject changed or referenced records so Undo never clears room assignments or device-row links implicitly +- `project-building.insert/.update/.delete` persist complete building records; + deleting a building with assigned floors is rejected. Floor commands carry + `buildingId` since command version 2; version 1 floor commands from older + history are upgraded with `buildingId = null` before execution - the floor and room POST routes require the current expected revision and the project page tracks the returned history state diff --git a/docs/revit-requirements-llm-context.md b/docs/revit-requirements-llm-context.md index 314820b..cf10324 100644 --- a/docs/revit-requirements-llm-context.md +++ b/docs/revit-requirements-llm-context.md @@ -1,5 +1,9 @@ # Kontext für die Revit-Anforderungsplanung +**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann +über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. +Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md). + ## Zweck dieser Datei Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index b665e85..f36d60d 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -532,6 +532,10 @@ Acceptance criteria: ## Phase 14: External Model Round-Trip +**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann +über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. +Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md). + Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe [Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md) @@ -540,7 +544,7 @@ und die verbindlichen Goal: -Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format. +Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import). Tasks: diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 519fd8d..e7821b4 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -47,8 +47,9 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase F: documentation and full GUI verification. - [ ] Keep full electrical sizing and cable-dimensioning rules separate until the planner supplies the calculation requirements. The optional per-circuit - cable sizing module (`docs/cable-sizing-module.md`) exists; its tables must - be confirmed by the planner against DIN VDE 0298-4. + cable sizing module (`docs/cable-sizing-module.md`) exists; its supported + tables were confirmed against DIN VDE 0298-4:2023-06 with + Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked. - Detailed specification: `docs/spec/09-distribution-board-components-and-protection-groups.md` @@ -143,4 +144,5 @@ The pre-release cleanup established the following compatibility boundary: The Revit/CSV/IFCGUID round-trip follows the distribution-board component foundation as a separate jointly planned phase and must not be inferred from -these tasks. +these tasks. It is currently deferred and will later use the CSV +export/import of the Linear plugin in Revit, not Dynamo. diff --git a/docs/spec/revit-csv-integration-requirements.md b/docs/spec/revit-csv-integration-requirements.md index 973c1a6..4ce65ee 100644 --- a/docs/spec/revit-csv-integration-requirements.md +++ b/docs/spec/revit-csv-integration-requirements.md @@ -2,6 +2,19 @@ ## 1. Status und Verbindlichkeit +> **Zurückgestellt (September 2026).** Die weitere Revit-Arbeit +> (Editor-Oberfläche zur Objektzuordnung, Folgeimport, Rückexport) wird später +> angegangen. Wenn sie wieder aufgenommen wird, bleibt der Austausch +> CSV-basiert: Export und Import laufen über das Linear-Plugin in Revit. Das +> entspricht der Vorgabe des internen Revit/CAD-Lenkungskreises, weil dabei +> nicht direkt per Dynamo im Modell gearbeitet wird. Eine Dynamo- oder +> API-Anbindung ist nicht vorgesehen; die früheren Vorüberlegungen liegen unter +> [docs/archive](../archive/revit-dynamo-leistungsbilanz-integration-konzept.md). +> Der Aufbau der Linear-CSV ist noch offen. Vor der Wiederaufnahme wird ein +> Musterexport gegen die bestehende projektweite CSV-Konfiguration und den +> Parser geprüft; erst danach wird entschieden, was anzupassen ist. Die bereits +> umgesetzten Schritte bleiben unverändert nutzbar. + Dieses Dokument ist die verbindliche fachliche und technische Arbeitsgrundlage für **Phase 14: External Model Round-Trip**. Es unterscheidet ausdrücklich zwischen: @@ -1338,7 +1351,7 @@ Diese Punkte blockieren die Erstellung dieser Spezifikation nicht. - automatische raumbezogene Stromkreisvorschläge; - projektübergreifend wiederverwendbare Mappingprofile; - Teilmodelle und mehrere externe Quellen; -- Dynamo/API/JSON-Transportadapter; +- Dynamo/API/JSON-Transportadapter (nicht vorgesehen, siehe Abschnitt 1); - Rollen und Berechtigungen; - Hintergrundjobs; - PostgreSQL. diff --git a/docs/spec/revit-csv-phase-14-audit-and-plan.md b/docs/spec/revit-csv-phase-14-audit-and-plan.md index be50eb1..a4c21e4 100644 --- a/docs/spec/revit-csv-phase-14-audit-and-plan.md +++ b/docs/spec/revit-csv-phase-14-audit-and-plan.md @@ -2,6 +2,10 @@ ## Status +**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann +über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo. +Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md). + Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen. Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem diff --git a/dynamo/01_check_model_identity.py b/dynamo/01_check_model_identity.py deleted file mode 100644 index a3fe009..0000000 --- a/dynamo/01_check_model_identity.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node. - -Optional Dynamo input: - IN[0]: output directory or complete .json file path - -The script intentionally performs no Revit transaction and changes no model -data. OUT contains a compact summary plus the complete report. -""" - -import datetime -import json -import os -import platform -import sys -import tempfile -import traceback - -import clr - -clr.AddReference("RevitAPI") -clr.AddReference("RevitServices") - -from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402 -from RevitServices.Persistence import DocumentManager # noqa: E402 - - -CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId") - - -def safe_text(value): - if value is None: - return None - try: - return str(value) - except Exception: - return None - - -def element_id_text(element_id): - if element_id is None: - return None - try: - return str(element_id.Value) - except Exception: - try: - return str(element_id.IntegerValue) - except Exception: - return safe_text(element_id) - - -def forge_type_id_text(value): - if value is None: - return None - try: - return value.TypeId - except Exception: - return safe_text(value) - - -def parameter_value(parameter): - result = { - "hasValue": False, - "raw": None, - "display": None, - } - try: - result["hasValue"] = bool(parameter.HasValue) - except Exception: - pass - - try: - storage_type = parameter.StorageType - if storage_type == StorageType.String: - result["raw"] = parameter.AsString() - elif storage_type == StorageType.Integer: - result["raw"] = int(parameter.AsInteger()) - elif storage_type == StorageType.Double: - result["raw"] = float(parameter.AsDouble()) - elif storage_type == StorageType.ElementId: - result["raw"] = element_id_text(parameter.AsElementId()) - except Exception as error: - result["readError"] = safe_text(error) - - try: - result["display"] = parameter.AsValueString() - except Exception: - pass - return result - - -def describe_parameter(parameter): - definition = None - try: - definition = parameter.Definition - except Exception: - pass - - name = None - if definition is not None: - try: - name = definition.Name - except Exception: - pass - - is_shared = False - try: - is_shared = bool(parameter.IsShared) - except Exception: - pass - - shared_guid = None - if is_shared: - try: - shared_guid = str(parameter.GUID) - except Exception: - pass - - data_type = None - group_type = None - if definition is not None: - try: - data_type = forge_type_id_text(definition.GetDataType()) - except Exception: - pass - try: - group_type = forge_type_id_text(definition.GetGroupTypeId()) - except Exception: - pass - - unit_type = None - try: - unit_type = forge_type_id_text(parameter.GetUnitTypeId()) - except Exception: - pass - - try: - storage_type = str(parameter.StorageType) - except Exception: - storage_type = None - - try: - is_read_only = bool(parameter.IsReadOnly) - except Exception: - is_read_only = None - - try: - user_modifiable = bool(parameter.UserModifiable) - except Exception: - user_modifiable = None - - return { - "name": name, - "parameterId": element_id_text(getattr(parameter, "Id", None)), - "isShared": is_shared, - "sharedGuid": shared_guid, - "storageType": storage_type, - "dataTypeId": data_type, - "groupTypeId": group_type, - "unitTypeId": unit_type, - "isReadOnly": is_read_only, - "userModifiable": user_modifiable, - "value": parameter_value(parameter), - } - - -def sorted_parameters(element): - parameters = [] - try: - parameters = [describe_parameter(parameter) for parameter in element.Parameters] - except Exception: - return [] - return sorted( - parameters, - key=lambda parameter: ( - (parameter.get("name") or "").casefold(), - parameter.get("parameterId") or "", - ), - ) - - -def named_parameter_occurrences(element, parameter_name): - result = [] - try: - parameters = element.GetParameters(parameter_name) - if parameters is not None: - result = [describe_parameter(parameter) for parameter in parameters] - except Exception: - parameter = None - try: - parameter = element.LookupParameter(parameter_name) - except Exception: - pass - if parameter is not None: - result = [describe_parameter(parameter)] - return result - - -def loaded_assembly_versions(): - result = {} - try: - from System import AppDomain - - for assembly in AppDomain.CurrentDomain.GetAssemblies(): - try: - name = assembly.GetName() - simple_name = str(name.Name) - if simple_name in ( - "DynamoCore", - "DynamoCoreWpf", - "DynamoRevitDS", - "RevitAPI", - "RevitServices", - ): - result[simple_name] = str(name.Version) - except Exception: - continue - except Exception: - pass - return dict(sorted(result.items())) - - -def get_cloud_identity(document): - result = {"isModelInCloud": False} - try: - result["isModelInCloud"] = bool(document.IsModelInCloud) - except Exception: - return result - if not result["isModelInCloud"]: - return result - - try: - model_path = document.GetCloudModelPath() - result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( - model_path - ) - for property_name, output_name in ( - ("GetProjectGUID", "projectGuid"), - ("GetModelGUID", "modelGuid"), - ): - try: - result[output_name] = str(getattr(model_path, property_name)()) - except Exception: - pass - except Exception as error: - result["readError"] = safe_text(error) - return result - - -def get_worksharing_identity(document): - result = {"isWorkshared": False} - try: - result["isWorkshared"] = bool(document.IsWorkshared) - except Exception: - return result - if not result["isWorkshared"]: - return result - - try: - model_path = document.GetWorksharingCentralModelPath() - result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath( - model_path - ) - except Exception as error: - result["readError"] = safe_text(error) - return result - - -def resolve_output_path(configured_path, report_name): - timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") - default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") - raw_path = safe_text(configured_path) - if raw_path is None or not raw_path.strip(): - directory = default_directory - file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") - else: - expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) - if expanded.lower().endswith(".json"): - file_path = expanded - directory = os.path.dirname(file_path) - else: - directory = expanded - file_path = os.path.join(directory, report_name + "-" + timestamp + ".json") - if not directory: - directory = os.getcwd() - if not os.path.isdir(directory): - os.makedirs(directory) - return file_path - - -def write_json(file_path, payload): - temporary_path = file_path + ".tmp" - with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: - json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) - output.write("\n") - os.replace(temporary_path, file_path) - - -def get_input(index, default=None): - values = globals().get("IN", []) - try: - value = values[index] - return default if value is None else value - except Exception: - return default - - -def build_report(): - document = DocumentManager.Instance.CurrentDBDocument - if document is None: - raise RuntimeError("No active Revit document is available.") - - project_information = document.ProjectInformation - if project_information is None: - raise RuntimeError("The active document has no Project Information element.") - - application = document.Application - checked_parameters = { - name: named_parameter_occurrences(project_information, name) - for name in CHECKED_PARAMETER_NAMES - } - warnings = [] - for name in CHECKED_PARAMETER_NAMES: - occurrences = checked_parameters[name] - populated = [ - parameter - for parameter in occurrences - if parameter.get("value", {}).get("raw") not in (None, "") - ] - if not occurrences: - warnings.append(name + " is not bound to Project Information.") - elif not populated: - warnings.append(name + " exists but has no value on Project Information.") - elif len(occurrences) > 1: - warnings.append(name + " occurs more than once; use a shared-parameter GUID later.") - - return { - "reportSchemaVersion": 1, - "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "readOnly": True, - "environment": { - "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), - "revitVersionName": safe_text(getattr(application, "VersionName", None)), - "revitSubVersionNumber": safe_text( - getattr(application, "SubVersionNumber", None) - ), - "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), - "pythonVersion": platform.python_version(), - "assemblies": loaded_assembly_versions(), - }, - "document": { - "title": safe_text(document.Title), - "pathName": safe_text(document.PathName), - "isFamilyDocument": bool(document.IsFamilyDocument), - "cloud": get_cloud_identity(document), - "worksharing": get_worksharing_identity(document), - }, - "modelIdentityCandidates": { - "projectInformationUniqueId": safe_text(project_information.UniqueId), - "projectInformationElementId": element_id_text(project_information.Id), - "checkedProjectParameters": checked_parameters, - }, - "projectInformationParameters": sorted_parameters(project_information), - "warnings": warnings, - } - - -try: - report = build_report() - output_path = resolve_output_path(get_input(0), "model-identity") - write_json(output_path, report) - OUT = { - "ok": True, - "filePath": output_path, - "projectInformationUniqueId": report["modelIdentityCandidates"][ - "projectInformationUniqueId" - ], - "warnings": report["warnings"], - "report": report, - } -except Exception as error: - OUT = { - "ok": False, - "error": safe_text(error), - "traceback": traceback.format_exc(), - } diff --git a/dynamo/02_export_electrical_fixture_parameter_inventory.py b/dynamo/02_export_electrical_fixture_parameter_inventory.py deleted file mode 100644 index 440a222..0000000 --- a/dynamo/02_export_electrical_fixture_parameter_inventory.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Export all Electrical Fixtures instance/type parameters from Revit 2026. - -Optional Dynamo inputs: - IN[0]: output directory or complete .json file path - IN[1]: include empty parameters (default True) - IN[2]: maximum aggregated sample values (default 5) - -The script is read-only and performs no Revit transaction. -""" - -import datetime -import json -import os -import platform -import sys -import tempfile -import traceback - -import clr - -clr.AddReference("RevitAPI") -clr.AddReference("RevitServices") - -from Autodesk.Revit.DB import ( # noqa: E402 - BuiltInCategory, - FilteredElementCollector, - ModelPathUtils, - StorageType, -) -from RevitServices.Persistence import DocumentManager # noqa: E402 - - -def safe_text(value): - if value is None: - return None - try: - return str(value) - except Exception: - return None - - -def element_id_text(element_id): - if element_id is None: - return None - try: - return str(element_id.Value) - except Exception: - try: - return str(element_id.IntegerValue) - except Exception: - return safe_text(element_id) - - -def forge_type_id_text(value): - if value is None: - return None - try: - return value.TypeId - except Exception: - return safe_text(value) - - -def parameter_value(parameter): - result = {"hasValue": False, "raw": None, "display": None} - try: - result["hasValue"] = bool(parameter.HasValue) - except Exception: - pass - - try: - storage_type = parameter.StorageType - if storage_type == StorageType.String: - result["raw"] = parameter.AsString() - elif storage_type == StorageType.Integer: - result["raw"] = int(parameter.AsInteger()) - elif storage_type == StorageType.Double: - result["raw"] = float(parameter.AsDouble()) - elif storage_type == StorageType.ElementId: - result["raw"] = element_id_text(parameter.AsElementId()) - except Exception as error: - result["readError"] = safe_text(error) - - try: - result["display"] = parameter.AsValueString() - except Exception: - pass - return result - - -def describe_parameter(parameter, scope): - definition = None - try: - definition = parameter.Definition - except Exception: - pass - - name = None - data_type = None - group_type = None - if definition is not None: - try: - name = definition.Name - except Exception: - pass - try: - data_type = forge_type_id_text(definition.GetDataType()) - except Exception: - pass - try: - group_type = forge_type_id_text(definition.GetGroupTypeId()) - except Exception: - pass - - is_shared = False - try: - is_shared = bool(parameter.IsShared) - except Exception: - pass - - shared_guid = None - if is_shared: - try: - shared_guid = str(parameter.GUID) - except Exception: - pass - - unit_type = None - try: - unit_type = forge_type_id_text(parameter.GetUnitTypeId()) - except Exception: - pass - - try: - storage_type = str(parameter.StorageType) - except Exception: - storage_type = None - - try: - is_read_only = bool(parameter.IsReadOnly) - except Exception: - is_read_only = None - - try: - user_modifiable = bool(parameter.UserModifiable) - except Exception: - user_modifiable = None - - return { - "scope": scope, - "name": name, - "parameterId": element_id_text(getattr(parameter, "Id", None)), - "isShared": is_shared, - "sharedGuid": shared_guid, - "storageType": storage_type, - "dataTypeId": data_type, - "groupTypeId": group_type, - "unitTypeId": unit_type, - "isReadOnly": is_read_only, - "userModifiable": user_modifiable, - "value": parameter_value(parameter), - } - - -def has_meaningful_value(parameter_description): - value = parameter_description.get("value", {}) - return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get( - "display" - ) not in (None, "") - - -def read_parameters(element, scope, include_empty): - result = [] - try: - for parameter in element.Parameters: - description = describe_parameter(parameter, scope) - if include_empty or has_meaningful_value(description): - result.append(description) - except Exception as error: - return [], [safe_text(error)] - result.sort( - key=lambda parameter: ( - (parameter.get("name") or "").casefold(), - parameter.get("parameterId") or "", - ) - ) - return result, [] - - -def read_space(element, document): - try: - space = element.Space - except Exception as error: - return None, safe_text(error) - if space is None: - return None, None - - level_name = None - try: - level = document.GetElement(space.LevelId) - level_name = None if level is None else safe_text(level.Name) - except Exception: - pass - - return { - "uniqueId": safe_text(space.UniqueId), - "elementId": element_id_text(space.Id), - "number": safe_text(getattr(space, "Number", None)), - "name": safe_text(getattr(space, "Name", None)), - "levelName": level_name, - }, None - - -def read_family_identity(element, document): - symbol = None - try: - symbol = element.Symbol - except Exception: - try: - symbol = document.GetElement(element.GetTypeId()) - except Exception: - pass - - family_name = None - type_name = None - type_unique_id = None - type_element_id = None - if symbol is not None: - try: - family_name = safe_text(symbol.Family.Name) - except Exception: - family_name = safe_text(getattr(symbol, "FamilyName", None)) - type_name = safe_text(getattr(symbol, "Name", None)) - type_unique_id = safe_text(getattr(symbol, "UniqueId", None)) - type_element_id = element_id_text(getattr(symbol, "Id", None)) - - return { - "familyName": family_name, - "typeName": type_name, - "typeUniqueId": type_unique_id, - "typeElementId": type_element_id, - }, symbol - - -def parameter_inventory_key(parameter): - stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or "" - return "|".join( - ( - parameter.get("scope") or "", - stable_id, - parameter.get("name") or "", - parameter.get("dataTypeId") or "", - ) - ) - - -def sample_value_key(value): - return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - - -def add_to_inventory(inventory, parameter, max_samples): - key = parameter_inventory_key(parameter) - entry = inventory.get(key) - if entry is None: - entry = { - "scope": parameter.get("scope"), - "name": parameter.get("name"), - "parameterId": parameter.get("parameterId"), - "isShared": parameter.get("isShared"), - "sharedGuid": parameter.get("sharedGuid"), - "storageType": parameter.get("storageType"), - "dataTypeId": parameter.get("dataTypeId"), - "groupTypeId": parameter.get("groupTypeId"), - "unitTypeId": parameter.get("unitTypeId"), - "occurrenceCount": 0, - "populatedCount": 0, - "sampleValues": [], - "_sampleKeys": set(), - } - inventory[key] = entry - entry["occurrenceCount"] += 1 - if has_meaningful_value(parameter): - entry["populatedCount"] += 1 - value = parameter.get("value") - value_key = sample_value_key(value) - if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]: - entry["_sampleKeys"].add(value_key) - entry["sampleValues"].append(value) - - -def finalize_inventory(inventory): - result = [] - for entry in inventory.values(): - clean_entry = dict(entry) - clean_entry.pop("_sampleKeys", None) - result.append(clean_entry) - return sorted( - result, - key=lambda entry: ( - entry.get("scope") or "", - (entry.get("name") or "").casefold(), - entry.get("sharedGuid") or entry.get("parameterId") or "", - ), - ) - - -def loaded_assembly_versions(): - result = {} - try: - from System import AppDomain - - for assembly in AppDomain.CurrentDomain.GetAssemblies(): - try: - name = assembly.GetName() - simple_name = str(name.Name) - if simple_name in ( - "DynamoCore", - "DynamoCoreWpf", - "DynamoRevitDS", - "RevitAPI", - "RevitServices", - ): - result[simple_name] = str(name.Version) - except Exception: - continue - except Exception: - pass - return dict(sorted(result.items())) - - -def resolve_output_path(configured_path): - timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") - default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo") - raw_path = safe_text(configured_path) - if raw_path is None or not raw_path.strip(): - directory = default_directory - file_path = os.path.join( - directory, "electrical-fixture-parameters-" + timestamp + ".json" - ) - else: - expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip()))) - if expanded.lower().endswith(".json"): - file_path = expanded - directory = os.path.dirname(file_path) - else: - directory = expanded - file_path = os.path.join( - directory, "electrical-fixture-parameters-" + timestamp + ".json" - ) - if not directory: - directory = os.getcwd() - if not os.path.isdir(directory): - os.makedirs(directory) - return file_path - - -def write_json(file_path, payload): - temporary_path = file_path + ".tmp" - with open(temporary_path, "w", encoding="utf-8", newline="\n") as output: - json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True) - output.write("\n") - os.replace(temporary_path, file_path) - - -def get_input(index, default=None): - values = globals().get("IN", []) - try: - value = values[index] - return default if value is None else value - except Exception: - return default - - -def build_report(include_empty, max_samples): - document = DocumentManager.Instance.CurrentDBDocument - if document is None: - raise RuntimeError("No active Revit document is available.") - if document.IsFamilyDocument: - raise RuntimeError("Open a Revit project document, not a family document.") - - application = document.Application - collector = ( - FilteredElementCollector(document) - .OfCategory(BuiltInCategory.OST_ElectricalFixtures) - .WhereElementIsNotElementType() - ) - source_elements = list(collector) - source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "") - - elements = [] - types_by_unique_id = {} - inventory = {} - errors = [] - elements_without_space = 0 - - for element in source_elements: - element_errors = [] - try: - family_identity, symbol = read_family_identity(element, document) - instance_parameters, parameter_errors = read_parameters( - element, "instance", include_empty - ) - element_errors.extend(parameter_errors) - for parameter in instance_parameters: - add_to_inventory(inventory, parameter, max_samples) - - type_unique_id = family_identity.get("typeUniqueId") - if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id: - type_parameters, type_errors = read_parameters(symbol, "type", include_empty) - element_errors.extend(type_errors) - for parameter in type_parameters: - add_to_inventory(inventory, parameter, max_samples) - types_by_unique_id[type_unique_id] = { - **family_identity, - "parameters": type_parameters, - } - - space, space_error = read_space(element, document) - if space_error: - element_errors.append("MEP Space: " + space_error) - if space is None: - elements_without_space += 1 - - elements.append( - { - "uniqueId": safe_text(element.UniqueId), - "elementId": element_id_text(element.Id), - "categoryName": safe_text( - None if element.Category is None else element.Category.Name - ), - "family": family_identity, - "space": space, - "instanceParameters": instance_parameters, - "warnings": element_errors, - } - ) - except Exception as error: - errors.append( - { - "uniqueId": safe_text(getattr(element, "UniqueId", None)), - "elementId": element_id_text(getattr(element, "Id", None)), - "error": safe_text(error), - } - ) - - return { - "reportSchemaVersion": 1, - "generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), - "readOnly": True, - "complete": len(errors) == 0, - "scope": { - "builtInCategory": "OST_ElectricalFixtures", - "wholeDocument": True, - "elementTypesExcluded": True, - "includeEmptyParameters": include_empty, - }, - "environment": { - "revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)), - "revitVersionName": safe_text(getattr(application, "VersionName", None)), - "revitSubVersionNumber": safe_text( - getattr(application, "SubVersionNumber", None) - ), - "pythonImplementation": safe_text(getattr(sys.implementation, "name", None)), - "pythonVersion": platform.python_version(), - "assemblies": loaded_assembly_versions(), - }, - "document": { - "title": safe_text(document.Title), - "pathName": safe_text(document.PathName), - "projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId), - }, - "summary": { - "elementCount": len(source_elements), - "exportedElementCount": len(elements), - "typeCount": len(types_by_unique_id), - "parameterDefinitionCount": len(inventory), - "elementsWithoutMepSpace": elements_without_space, - "elementErrorCount": len(errors), - }, - "parameterInventory": finalize_inventory(inventory), - "types": sorted( - types_by_unique_id.values(), - key=lambda entry: ( - (entry.get("familyName") or "").casefold(), - (entry.get("typeName") or "").casefold(), - entry.get("typeUniqueId") or "", - ), - ), - "elements": elements, - "errors": errors, - } - - -try: - include_empty_input = get_input(1, True) - include_empty = bool(include_empty_input) - try: - max_samples = int(get_input(2, 5)) - except Exception: - max_samples = 5 - max_samples = max(0, min(max_samples, 50)) - - report = build_report(include_empty, max_samples) - output_path = resolve_output_path(get_input(0)) - write_json(output_path, report) - OUT = { - "ok": True, - "filePath": output_path, - "complete": report["complete"], - "summary": report["summary"], - "errors": report["errors"], - } -except Exception as error: - OUT = { - "ok": False, - "error": safe_text(error), - "traceback": traceback.format_exc(), - } diff --git a/dynamo/README.md b/dynamo/README.md deleted file mode 100644 index 800eded..0000000 --- a/dynamo/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Revit 2026 / Dynamo diagnostics - -This directory contains self-contained Python scripts for a Dynamo **Python -Script** node. They use only Dynamo's built-in Revit integration, the Revit API -and the Python standard library. No Dynamo package is required. - -The scripts are read-only. They do not start a Revit transaction and do not -change the open model. - -## Python engine - -Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with -Revit; optional PythonNet3 packages are not required by these diagnostics. - -## 01 - Check model identity - -File: `01_check_model_identity.py` - -The script reports: - -- Revit, Dynamo and Python versions; -- `ProjectInformation.UniqueId` as a native model-identity candidate; -- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project - Information; -- all Project Information parameters; -- optional cloud/worksharing identity information when the API exposes it. - -Input `IN[0]` is optional. It may be either an output directory or a complete -`.json` file path. With no input, the report is written below the current -Windows temporary directory in `leistungsbilanz-dynamo`. - -## 02 - Inventory Electrical Fixtures parameters - -File: `02_export_electrical_fixture_parameter_inventory.py` - -The script reads every instance of -`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It -exports: - -- element, family, type and MEP Space identities; -- every instance parameter and value; -- every unique family-type parameter and value; -- an aggregated parameter inventory with occurrence counts and sample values; -- per-element warnings instead of aborting at the first unreadable element. - -Inputs: - -- `IN[0]` (optional): output directory or complete `.json` path; -- `IN[1]` (optional): include empty parameters, default `true`; -- `IN[2]` (optional): maximum sample values per aggregated parameter, default - `5`. - -The default output location is again the Windows temporary directory. The -generated report can contain model paths and project-specific parameter values; -review it before sharing or committing it. - -## Running a script - -1. Open the target model in Revit 2026. -2. Open Dynamo from **Manage > Visual Programming > Dynamo**. -3. Create a graph and add a **Python Script** node. -4. Select the `CPython3` engine for the node. -5. Copy the complete content of the desired `.py` file into the node. -6. Optionally connect a String node containing the output path to `IN[0]`. -7. Run the graph and inspect `OUT` for status, counts and the generated path. - -For the first test, run `01_check_model_identity.py` in the Revit main model. -Then run the parameter inventory. Keep both generated JSON files so their -structure can be checked before the production snapshot DTO is finalized. diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 8680f59..fa91cc4 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -5,6 +5,10 @@ const dbPath = path.resolve("data", "leistungsbilanz.db"); const db = new Database(dbPath, { readonly: true }); const requiredTables = [ + "buildings", + "estimate_balances", + "estimate_groups", + "estimate_rows", "circuit_sections", "circuits", "circuit_device_rows", @@ -12,6 +16,8 @@ const requiredTables = [ "circuit_protection_devices", "distribution_board_components", "distribution_board_component_protection_devices", + "document_branding_assets", + "document_branding_settings", ]; const tablePlaceholders = requiredTables.map(() => "?").join(", "); @@ -87,6 +93,10 @@ const requiredProjectColumns = [ "description", "is_public_building", "enabled_distribution_board_supply_types", + "site_address", + "work_stage", + "project_lead", + "project_editor", ]; const projectColumns = new Set( db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name) @@ -94,10 +104,21 @@ const projectColumns = new Set( const missingProjectColumns = requiredProjectColumns.filter( (name) => !projectColumns.has(name) ); +const requiredFloorColumns = ["building_id"]; +const floorColumns = new Set( + db.prepare("PRAGMA table_info(floors)").all().map((column) => column.name) +); +const missingFloorColumns = requiredFloorColumns.filter( + (name) => !floorColumns.has(name) +); const requiredDistributionBoardColumns = [ "floor_id", "supply_type", "simultaneity_factor", + "board_number", + "location", + "fed_from", + "remark", ]; const distributionBoardColumns = new Set( db @@ -145,6 +166,7 @@ console.log( requiredCircuitSectionColumns.join(", ") ); console.log("Required project columns:", requiredProjectColumns.join(", ")); +console.log("Required floor columns:", requiredFloorColumns.join(", ")); console.log( "Required distribution-board columns:", requiredDistributionBoardColumns.join(", ") @@ -161,6 +183,11 @@ if (missing.length > 0) { process.exit(1); } +if (missingFloorColumns.length > 0) { + console.error("Missing floor columns:", missingFloorColumns.join(", ")); + process.exit(1); +} + if (missingProjectDeviceColumns.length > 0) { console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", ")); process.exit(1); diff --git a/src/app/globals.css b/src/app/globals.css index fe81d07..e5474b8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -220,7 +220,7 @@ a { --bs-alert-border-color: #205a38; } -/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */ +/* ── App-Shell / Seitenleiste (alle Seiten außer der Seitenansicht) ──── */ .app-shell { display: grid; @@ -381,6 +381,263 @@ a { font-weight: 600; } +/* ── Projektübersicht: Icon-Aktionen und einklappbare Filterleisten ───── + Aktionen nutzen IconButton/IconLink wie die Stromkreisliste; der Rahmen + kommt hier aus dem Kontext `.overview-actions`. Filterleisten folgen der + PM-App (Suche + Mehrfachauswahl mit „Alle“ + „Zurücksetzen“), angepasst an + die Tokens dieser App. */ + +.btn-with-icon { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.overview-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; +} + +.overview-actions .icon-button { + min-width: 1.9rem; + min-height: 1.85rem; + padding: 0.2rem 0.45rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.overview-actions .icon-button:disabled { + opacity: 0.45; +} + +.overview-actions .icon-button-primary { + border-color: var(--color-primary); + background: var(--color-primary); + color: #fff; +} + +.overview-actions .icon-button-primary:not(:disabled):hover { + border-color: var(--color-primary-dark); + background: var(--color-primary-dark); + color: #fff; +} + +:root[data-bs-theme="dark"] .overview-actions .icon-button-primary { + border-color: var(--color-accent); + background: var(--color-accent); +} + +:root[data-bs-theme="dark"] .overview-actions .icon-button-primary:not(:disabled):hover { + border-color: var(--color-accent-pale); + background: var(--color-accent-pale); + color: var(--color-primary-deep); +} + +.overview-actions .icon-button-danger { + color: var(--color-danger); +} + +.filter-panel { + padding: 0.55rem 1rem 0.7rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} + +.filter-panel-header { + display: flex; + align-items: center; + gap: 10px; +} + +.filter-panel-header .filter-panel-toggle { + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 6px; + background: none; + color: var(--color-ink-soft); +} + +.filter-panel-header .filter-panel-toggle:hover { + background: var(--color-surface); +} + +.filter-panel-label, +.filter-bar-label { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-ink-soft); +} + +.filter-panel-badge { + padding: 3px 10px; + border-radius: 999px; + background: #fbe9d9; + color: var(--color-attention-dark); + font-size: 0.72rem; + font-weight: 500; + white-space: nowrap; +} + +:root[data-bs-theme="dark"] .filter-panel-badge { + background: #3a2e1c; + color: #f0b880; +} + +.filter-panel-count { + margin-left: auto; + font-size: 0.78rem; + color: var(--color-ink-soft); + white-space: nowrap; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-sm) var(--space-md); + margin-top: 0.5rem; +} + +.filter-bar-field, +.filter-dropdown { + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; +} + +.filter-bar-search, +.filter-dropdown-toggle { + min-height: 2.1rem; + padding: 0.35rem 0.65rem; + border: 1.5px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-ink); + font: inherit; + font-size: 0.82rem; +} + +.filter-bar-search { + width: 15em; +} + +.filter-bar-search:focus, +.filter-dropdown-toggle:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; + border-color: var(--color-accent); +} + +.filter-dropdown-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 11em; + max-width: 18em; + font-weight: 500; + cursor: pointer; +} + +.filter-dropdown-toggle:hover, +.filter-dropdown-toggle.is-active { + border-color: var(--color-accent); +} + +.filter-dropdown-toggle.is-active { + color: var(--color-primary); +} + +:root[data-bs-theme="dark"] .filter-dropdown-toggle.is-active { + color: var(--color-accent-pale); +} + +.filter-dropdown-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-dropdown-toggle svg { + flex: none; + color: var(--color-ink-soft); +} + +.filter-dropdown-panel { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 220px; + max-height: 280px; + overflow-y: auto; + padding: 8px; + border: 1.5px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + box-shadow: 0 6px 20px var(--shadow-menu); +} + +.filter-dropdown-option { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 6px 8px; + border-radius: 6px; + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 400; + cursor: pointer; +} + +.filter-dropdown-option:hover { + background: var(--color-surface); +} + +.filter-dropdown-option input { + accent-color: var(--color-primary); +} + +.filter-dropdown-option-all { + margin-bottom: 2px; + padding-bottom: 8px; + border-bottom: 1px solid var(--color-border); + border-radius: 6px 6px 0 0; +} + +.filter-dropdown-empty { + padding: 6px 8px; + color: var(--color-ink-soft); + font-size: 0.8rem; +} + +.filter-bar .filter-bar-reset { + min-height: 2.1rem; + padding: 0.3rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg); + color: var(--color-ink); + font-size: 0.82rem; + font-weight: 500; +} + /* ── KPI-Kacheln ─────────────────────────────────────────────────────── */ .kpi-grid { @@ -431,12 +688,29 @@ a.kpi:hover { color: var(--color-accent-pale); } +/* Flächen-Look wie in der PM-App (Vorbild grow-monitor): gleichmäßiger + 1-px-Rahmen statt farbigem Balken an der Oberkante, größerer Radius und ein + weicher Streuschatten mit feiner heller Innenkante oben. Der Akzent kommt + über Kopfzeile und Inhalt, nicht über die Kante. Bootstraps shadow-sm + setzt box-shadow mit !important aus --bs-box-shadow-sm; die Karte legt diese + Variable auf denselben Schatten, damit Karten mit und ohne shadow-sm gleich + aussehen. */ .card { - border-radius: var(--radius); - border-top: 3px solid var(--color-primary); - box-shadow: - 0 1px 2px var(--shadow-soft), - 0 10px 24px -14px var(--shadow-strong); + --lb-card-shadow: + 0 4px 24px var(--shadow-soft), + 0 1px 0 rgba(255, 255, 255, 0.7) inset; + --bs-box-shadow-sm: var(--lb-card-shadow); + --bs-card-border-radius: 16px; + --bs-card-border-color: var(--color-border); + box-shadow: var(--lb-card-shadow); +} + +/* Auf dunklem Grund ein kräftigerer Streuschatten und nur eine schwache + Innenkante, sonst wirkt sie wie ein harter weißer Strich. */ +:root[data-bs-theme="dark"] .card { + --lb-card-shadow: + 0 4px 24px rgba(0, 0, 0, 0.4), + 0 1px 0 rgba(255, 255, 255, 0.05) inset; } .card-header { @@ -1324,6 +1598,38 @@ a.kpi:hover { outline-offset: -2px; } +/* Picker trigger on the selected GZF cell. Positioned over the cell so the + column width does not change while the selection moves. */ +.tree-grid td:has(> .cell-picker-trigger) { + position: relative; +} + +.tree-grid .cell-picker-trigger { + position: absolute; + top: 50%; + left: 0.2rem; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + border: 1px solid var(--accent-blue-strong-border); + border-radius: 3px; + padding: 0; + background: var(--panel-bg); + color: var(--color-primary); + cursor: pointer; +} + +:root[data-bs-theme="dark"] .tree-grid .cell-picker-trigger { + color: var(--color-accent-pale); +} + +.tree-grid .cell-picker-trigger:hover { + background: var(--accent-blue-soft-bg); +} + .tree-grid .section-title { display: flex; align-items: baseline; @@ -1555,3 +1861,851 @@ a.kpi:hover { .sidebar-collapsed .sidebar-label { display: none; } + +/* ── Gebäudestruktur (Gebäude → Etagen → Räume) ─────────────────────── */ + +.structure-columns { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--space-md); + align-items: start; +} + +@media (min-width: 992px) { + .structure-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.structure-column-title { + font-size: 1rem; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; +} + +.structure-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.4rem; + line-height: 1; +} + +.structure-back-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.45rem; + margin-top: 0.1rem; +} + +.structure-list { + max-height: 70vh; + overflow-y: auto; +} + +.structure-entry { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 0; +} + +.structure-entry-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: 0.55rem 0.75rem; + border: 0; + background: transparent; + color: inherit; + text-align: left; + font: inherit; +} + +button.structure-entry-main { + cursor: pointer; +} + +button.structure-entry-main:hover { + background: var(--surface-hover); +} + +button.structure-entry-main:focus-visible { + outline: 2px solid var(--accent-blue); + outline-offset: -2px; +} + +.structure-entry.is-selected { + background: var(--surface-selected); + box-shadow: inset 3px 0 0 var(--color-signal); +} + +.structure-entry.is-selected .structure-entry-label { + font-weight: 600; +} + +.structure-entry.is-selected button.structure-entry-main:hover { + background: transparent; +} + +.structure-entry-text { + min-width: 0; + display: flex; + flex-direction: column; +} + +.structure-entry-label { + overflow-wrap: anywhere; +} + +.structure-entry-hint { + font-size: 0.78rem; + color: var(--color-ink-soft); +} + +.structure-entry-meta { + flex: 0 0 auto; + font-size: 0.78rem; + color: var(--color-ink-soft); + white-space: nowrap; +} + +.structure-entry-actions { + flex: 0 0 auto; + display: inline-flex; + gap: 4px; + padding-right: 0.75rem; +} + +.structure-room-number { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.structure-group-label { + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-accent); + background: var(--panel-bg-subtle); + padding: 0.35rem 0.75rem; +} + +.structure-empty { + color: var(--color-ink-soft); + font-size: 0.9rem; +} + +/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */ +/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte + Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */ + +.estimate-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.estimate-save-status { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); + font-size: 0.8rem; +} + +.estimate-save-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-weight: 600; + border: 1px solid var(--color-border); + color: var(--color-ink-soft); + background: var(--color-bg); +} + +.estimate-save-badge.is-saved { + color: var(--color-signal-dark); + border-color: var(--color-signal); +} + +.estimate-save-badge.is-busy { + color: var(--color-warn); + border-color: var(--color-warn); +} + +.estimate-save-badge.is-error { + color: var(--color-danger); + border-color: var(--color-danger); + background: var(--color-danger-bg); +} + +.estimate-save-hint { + color: var(--color-danger); +} + +.estimate-header-fields { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm) var(--space-lg); + margin-bottom: var(--space-md); +} + +.estimate-header-field { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 10rem; +} + +.estimate-header-field.is-wide { + flex: 1 1 18rem; +} + +.estimate-header-label { + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-ink-soft); +} + +.estimate-header-value { + font-weight: 600; +} + +.estimate-header-input { + font-size: 0.95rem; + font-weight: 600; + border-color: var(--panel-border); +} + +.estimate-sheet-wrap { + overflow-x: auto; +} + +.estimate-sheet { + width: 100%; + min-width: 1400px; + table-layout: fixed; + border-collapse: collapse; + font-size: 0.82rem; + background: var(--panel-bg); + color: var(--text-strong); +} + +.estimate-comparison { + min-width: 640px; + table-layout: auto; +} + +.estimate-sheet th, +.estimate-sheet td { + border: 1px solid var(--panel-border); + padding: 2px 4px; + vertical-align: middle; +} + +.estimate-sheet thead th { + background: var(--surface-header-row); + font-size: 0.72rem; + font-weight: 700; + text-align: center; + line-height: 1.2; +} + +.estimate-th-hint { + display: block; + font-size: 0.68rem; + font-weight: 400; + color: var(--text-faint); +} + +.estimate-col-kg { width: 3rem; } +.estimate-col-supply { width: 4rem; } +.estimate-col-name { width: auto; } +.estimate-col-quantity { width: 4.8rem; } +.estimate-col-unit { width: 3.8rem; } +.estimate-col-connection { width: 5rem; } +.estimate-col-power { width: 6.4rem; } +.estimate-col-gzf { width: 6.8rem; } +.estimate-col-cosphi { width: 3.8rem; } +.estimate-col-result { width: 4.6rem; } +.estimate-col-sv { width: 3.2rem; } +.estimate-col-remark { width: 9rem; } +.estimate-col-actions { width: 2.4rem; } + +.estimate-cell-input, +.estimate-cell-select { + width: 100%; + min-width: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: inherit; + font: inherit; + padding: 1px 4px; + line-height: 1.4; +} + +.estimate-cell-select { + padding-right: 0; + cursor: pointer; +} + +.estimate-cell-input:hover:not(:disabled), +.estimate-cell-select:hover:not(:disabled) { + border-color: var(--panel-border-strong); +} + +.estimate-cell-input:focus, +.estimate-cell-select:focus { + outline: none; + border-color: var(--accent-blue); + background: var(--panel-bg); + box-shadow: 0 0 0 2px var(--accent-blue-soft-bg); +} + +.estimate-cell-input.is-numeric { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.estimate-cell-input.is-invalid, +.estimate-cell-input.is-invalid:focus, +.estimate-cell-input.is-invalid:hover { + border-color: var(--color-danger); + background: var(--color-danger-bg); +} + +.estimate-cell-input::placeholder { + color: var(--text-faint); + opacity: 0.7; +} + +.estimate-unit-input, +.estimate-gzf-input { + display: flex; + align-items: center; + gap: 2px; +} + +.estimate-unit-label { + flex: 0 0 auto; + font-size: 0.7rem; + color: var(--text-faint); + min-width: 1.9rem; +} + +.estimate-amev-button { + flex: 0 0 auto; + border: 1px solid var(--panel-border-strong); + border-radius: 3px; + background: var(--panel-bg-subtle); + color: var(--text-subtle); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 1px 3px; + line-height: 1.3; +} + +.estimate-amev-button:hover:not(:disabled) { + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.estimate-number { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.estimate-number.is-muted { + color: var(--text-faint); +} + +.estimate-number.is-over { + color: var(--color-attention-dark); +} + +.estimate-number.is-under { + color: var(--color-signal-dark); +} + +.estimate-center { + text-align: center; +} + +.estimate-cost-group-cell { + font-weight: 700; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.estimate-group-title-row td { + background: var(--surface-component-header); + font-weight: 700; +} + +.estimate-group-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-lg); +} + +.estimate-group-name { + font-size: 0.9rem; +} + +.estimate-group-editor { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-weight: 400; + font-size: 0.75rem; + color: var(--text-muted); +} + +.estimate-group-editor .estimate-cell-input { + width: 12rem; +} + +.estimate-group-actions { + display: flex; + justify-content: flex-end; + gap: 2px; +} + +.estimate-group-title-row .estimate-actions-cell { + width: auto; + white-space: nowrap; +} + +.estimate-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem 0.3rem; + line-height: 1; +} + +.estimate-actions-cell { + text-align: center; +} + +.estimate-empty-row td { + color: var(--text-faint); + font-style: italic; +} + +.estimate-subtotal-row td { + background: var(--panel-bg-subtle); + font-weight: 700; +} + +.estimate-total-label { + text-align: right; + font-weight: 700; +} + +.estimate-total-hint { + font-weight: 400; + color: var(--text-faint); +} + +.estimate-total-row td { + background: var(--surface-header-row); + font-weight: 600; +} + +.estimate-total-row.is-strong td { + font-weight: 800; + border-top: 2px solid var(--panel-border-strong); +} + +.estimate-add-group-row td { + background: var(--panel-bg); + padding: var(--space-sm) 4px; +} + +.estimate-unassigned-row td { + font-style: italic; + color: var(--text-muted); +} + +.estimate-collapse-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + padding: 0; +} + +.estimate-list-table .estimate-number { + font-variant-numeric: tabular-nums; +} + +/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */ +.estimate-print-value { + display: none; +} + +/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die + Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */ +.estimate-sheet td, +.estimate-sheet td.estimate-number, +.estimate-sheet .estimate-cell-input, +.estimate-sheet .estimate-cell-input.is-numeric { + text-align: center; +} + +.estimate-sheet .estimate-cell-select { + text-align: center; + text-align-last: center; +} + +.estimate-sheet .estimate-unit-input, +.estimate-sheet .estimate-gzf-input { + justify-content: center; +} + +.estimate-sheet td.estimate-name-cell, +.estimate-sheet td.estimate-name-cell .estimate-cell-input, +.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell), +.estimate-comparison td:nth-child(2) { + text-align: left; +} + +.estimate-sheet td.estimate-total-label { + text-align: right; +} + +/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css): + gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit + Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */ +.estimate-print-titleblock { + display: none; +} + +@page estimate-balance { + size: A4 landscape; + margin: 10mm 10mm 13mm; +} + +@media print { + body:has(.estimate-print-root) .sidebar, + .estimate-print-root .estimate-no-print, + .estimate-print-root .estimate-page-header, + .estimate-print-root .estimate-header-fields, + .estimate-print-root .alert, + .estimate-print-root input, + .estimate-print-root select, + .estimate-print-root button { + display: none !important; + } + + html:has(.estimate-print-root), + body:has(.estimate-print-root), + body:has(.estimate-print-root) .app-shell, + body:has(.estimate-print-root) .app-shell-content { + background: #fff !important; + } + + /* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den + Seitenrand dunkel. Im Druck gilt immer das helle Schema. */ + html:has(.estimate-print-root) { + color-scheme: light !important; + } + + .estimate-print-root .estimate-group-editor.is-empty { + display: none; + } + + /* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */ + .estimate-sheet .estimate-col-name { + width: 52mm; + } + + .estimate-sheet .estimate-col-remark { + width: 24mm; + } + + .estimate-sheet .estimate-col-supply { + width: 13mm; + } + + body:has(.estimate-print-root) .app-shell { + display: block; + } + + .estimate-print-root { + --ep-blue: #476088; + --ep-lilac: #9198c4; + --ep-line: #d6dae5; + --ep-tint: #f5f6fa; + --ep-text: #2b2b2b; + --ep-muted: #6b7280; + --ep-accent: #c9782a; + page: estimate-balance; + padding: 0 !important; + max-width: none; + color: var(--ep-text); + font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + color-scheme: light; + } + + .estimate-print-root * { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .estimate-print-root .estimate-print-value { + display: inline; + } + + /* Titelblock wie der Plankopf der Seitenansicht */ + .estimate-print-titleblock { + display: grid; + grid-template-columns: 30mm 1fr auto auto; + margin-bottom: 4mm; + border: 1.5px solid var(--ep-blue); + background: #fff; + font-size: 8.5pt; + break-inside: avoid; + } + + .estimate-print-titleblock > * { + padding: 2.5mm 3.5mm; + border-left: 1px solid var(--ep-line); + } + + .estimate-print-titleblock > :first-child { + border-left: none; + } + + .estimate-print-logo { + display: flex; + align-items: center; + justify-content: center; + min-height: 16mm; + color: var(--ep-blue); + font-weight: bold; + text-align: center; + } + + .estimate-print-logo img { + max-width: 100%; + max-height: 10mm; + } + + .estimate-print-kicker { + color: var(--ep-muted); + font-size: 7pt; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .estimate-print-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 1mm 4mm; + margin-top: 1mm; + } + + .estimate-print-stand { + color: var(--ep-blue); + font-size: 16pt; + font-weight: bold; + } + + .estimate-print-project { + font-size: 12pt; + font-weight: bold; + } + + .estimate-print-meta { + display: grid; + grid-template-columns: auto auto; + gap: 0.8mm 4mm; + align-content: center; + margin: 0; + font-size: 7.5pt; + } + + .estimate-print-meta div { + display: contents; + } + + .estimate-print-meta dt { + color: var(--ep-muted); + font-weight: normal; + } + + .estimate-print-meta dd { + margin: 0; + font-weight: bold; + } + + .estimate-print-meta.is-tinted { + background: var(--ep-tint); + } + + /* Karten und Ränder der Bildschirmansicht entfallen */ + .estimate-print-root .card { + border: 0; + box-shadow: none !important; + background: transparent; + } + + .estimate-print-root .card-body, + .estimate-print-root .card-header { + padding: 0; + background: transparent; + border: 0; + } + + .estimate-print-root .estimate-comparison-card { + margin-top: 6mm; + break-inside: avoid; + } + + .estimate-print-root .estimate-comparison-card .card-header { + margin-bottom: 2mm; + color: var(--ep-blue); + font-size: 10pt; + font-weight: bold; + } + + .estimate-sheet-wrap { + overflow: visible; + } + + /* Tabelle */ + .estimate-sheet { + min-width: 0; + table-layout: auto; + font-size: 7.4pt; + line-height: 1.2; + background: #fff; + color: var(--ep-text); + } + + .estimate-sheet th, + .estimate-sheet td { + border: 1px solid var(--ep-line) !important; + padding: 0.7mm 1.5mm; + background: #fff; + color: var(--ep-text); + } + + .estimate-sheet thead { + display: table-header-group; + } + + .estimate-sheet thead th { + background: var(--ep-blue) !important; + border-color: var(--ep-blue) !important; + color: #fff !important; + font-weight: bold; + vertical-align: bottom; + } + + .estimate-sheet thead .estimate-th-hint { + color: #dfe3f0 !important; + } + + .estimate-group-title-row td { + background: var(--ep-tint) !important; + border-top: 1.5px solid var(--ep-lilac) !important; + font-weight: bold; + } + + .estimate-group-title-row .estimate-cost-group-cell { + color: var(--ep-blue) !important; + } + + .estimate-group-title-row .estimate-group-editor { + color: var(--ep-muted); + font-weight: normal; + } + + .estimate-subtotal-row td { + border-bottom: 1.5px solid var(--ep-line) !important; + font-weight: bold; + } + + .estimate-subtotal-row .estimate-total-label { + color: var(--ep-muted); + } + + .estimate-total-row td { + background: var(--ep-tint) !important; + font-weight: bold; + } + + .estimate-total-row.is-strong td { + background: var(--ep-blue) !important; + border-color: var(--ep-blue) !important; + color: #fff !important; + } + + .estimate-total-row .estimate-total-hint { + color: var(--ep-muted); + font-weight: normal; + } + + .estimate-total-row.is-strong .estimate-total-hint { + color: #dfe3f0; + } + + .estimate-comparison .is-over { + color: var(--ep-accent) !important; + font-weight: bold; + } + + .estimate-comparison .is-under { + color: var(--ep-blue) !important; + font-weight: bold; + } + + .estimate-group-title-row, + .estimate-subtotal-row, + .estimate-total-row, + .estimate-row { + break-inside: avoid; + } + + .estimate-group-title-row { + break-after: avoid; + } + + /* Summen einmal am Ende, nicht auf jeder Seite */ + .estimate-sheet tfoot { + display: table-row-group; + break-inside: avoid; + } + + .estimate-col-actions { + width: 0; + } + + .estimate-unit-input, + .estimate-gzf-input { + justify-content: center; + } +} diff --git a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx index 89679cf..90893f6 100644 --- a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx +++ b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { ArrowLeft } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor"; @@ -10,9 +11,19 @@ export default function CircuitTreeEditPage() { return (
-
-

Stromkreisliste

-

Stromkreise und zugeordnete Geräte bearbeiten.

+
+ +
diff --git a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx new file mode 100644 index 0000000..584f5cf --- /dev/null +++ b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx @@ -0,0 +1,745 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { + ArrowLeft, + ChevronDown, + ChevronRight, + Printer, + Redo2, + RefreshCw, + Undo2, +} from "lucide-react"; +import { + ApiRequestError, + getEstimateBalance, + getEstimateComparison, + getProject, + getProjectHistory, + isRevisionConflictError, + redoProjectCommand, + undoProjectCommand, + updateEstimateBalance, +} from "../../../../../frontend/utils/api"; +import type { + EstimateBalanceDto, + EstimateComparisonDto, + ProjectDto, + ProjectHistoryStateDto, +} from "../../../../../frontend/types"; +import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance"; +import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal"; +import { + addEstimateGroup, + estimateBalanceFingerprint, + estimateTextLimits, + normalizeEstimateSortOrder, + toSaveableEstimateBalance, + updateEstimateGroup, + updateEstimateRow, + validateEstimateBalance, +} from "../../../../../frontend/estimate-balance/estimate-balance-document"; +import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format"; +import { + EstimateBalanceSheet, + type EstimateBalanceUpdate, +} from "../../../../../frontend/estimate-balance/estimate-balance-sheet"; +import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table"; +import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input"; +import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header"; +import { + EstimateGroupModal, + type EstimateGroupFormValues, +} from "../../../../../frontend/estimate-balance/estimate-group-modal"; + +const AUTOSAVE_DELAY_MS = 800; +const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen."; + +type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid"; + +type GroupDialog = { group: EstimateGroupSnapshot | null }; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function isNotFoundError(error: unknown) { + return error instanceof ApiRequestError && error.status === 404; +} + +export default function EstimateBalanceEditorPage() { + const params = useParams<{ projectId: string; balanceId: string }>(); + const { projectId, balanceId } = params; + const router = useRouter(); + + const [project, setProject] = useState(null); + const [draft, setDraft] = useState(null); + const [history, setHistory] = useState(null); + const [comparison, setComparison] = useState(null); + const [comparisonError, setComparisonError] = useState(null); + const [isComparisonOpen, setIsComparisonOpen] = useState(false); + const [saveStatus, setSaveStatus] = useState("saved"); + const [saveError, setSaveError] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isMissing, setIsMissing] = useState(false); + const [isHistoryBusy, setIsHistoryBusy] = useState(false); + const [invalidFieldKeys, setInvalidFieldKeys] = useState>( + () => new Set() + ); + const [groupDialog, setGroupDialog] = useState(null); + const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>( + null + ); + + // Refs carry the latest values into timers, unload handlers and chained + // async saves without re-creating them. + const draftRef = useRef(null); + const revisionRef = useRef(null); + const savedFingerprintRef = useRef(null); + const saveInFlightRef = useRef | null>(null); + const saveTimerRef = useRef | null>(null); + + useEffect(() => { + draftRef.current = draft; + }, [draft]); + + const applyServerBalance = useCallback((balance: EstimateBalanceDto) => { + const normalized = normalizeEstimateSortOrder(balance); + savedFingerprintRef.current = estimateBalanceFingerprint(normalized); + draftRef.current = normalized; + setDraft(normalized); + setSaveStatus("saved"); + setSaveError(null); + }, []); + + const reloadComparison = useCallback(async () => { + try { + setComparison(await getEstimateComparison(projectId, balanceId)); + setComparisonError(null); + } catch (err) { + setComparisonError( + errorMessage(err, "Der Vergleich konnte nicht geladen werden.") + ); + } + }, [balanceId, projectId]); + + /** Reloads project, document and history; replaces the local draft. */ + const reloadAll = useCallback(async () => { + const balanceRequest = getEstimateBalance(projectId, balanceId); + // Handled below; avoids an "unhandled rejection" while project/history load. + balanceRequest.catch(() => undefined); + // Project and history are applied even when the balance is missing, so + // Redo stays available after its creation was undone. + const [currentProject, currentHistory] = await Promise.all([ + getProject(projectId), + getProjectHistory(projectId).catch(() => null), + ]); + setProject(currentProject); + revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision; + setHistory(currentHistory); + const balance = await balanceRequest; + applyServerBalance(balance); + setIsMissing(false); + void reloadComparison(); + }, [applyServerBalance, balanceId, projectId, reloadComparison]); + + const handleReloadError = useCallback((err: unknown) => { + if (isNotFoundError(err)) { + setIsMissing(true); + setDraft(null); + draftRef.current = null; + return; + } + setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden.")); + }, []); + + useEffect(() => { + if (!projectId || !balanceId) { + return; + } + setIsLoading(true); + reloadAll() + .then(() => setError(null)) + .catch(handleReloadError) + .finally(() => setIsLoading(false)); + }, [balanceId, handleReloadError, projectId, reloadAll]); + + const clearSaveTimer = useCallback(() => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }, []); + + /** + * Saves the current draft if it differs from the last saved state. Saves + * never overlap: a save requested while another runs waits for it and then + * saves whatever changed in the meantime. Resolves to false when the draft + * could not be saved. + */ + const saveNow = useCallback(async (): Promise => { + clearSaveTimer(); + while (saveInFlightRef.current) { + await saveInFlightRef.current; + } + const current = draftRef.current; + const revision = revisionRef.current; + if (!current || revision === null) { + return true; + } + const fingerprint = estimateBalanceFingerprint(current); + if (fingerprint === savedFingerprintRef.current) { + setSaveStatus("saved"); + return true; + } + const problems = validateEstimateBalance(current); + if (problems.length) { + setSaveStatus("invalid"); + setSaveError(problems.join(" ")); + return false; + } + const body = toSaveableEstimateBalance(current); + + /** + * Another command changed the project revision. When this document is + * still unchanged on the server (e.g. an edit in a circuit list), the + * draft is kept and saved again; otherwise the server state wins. + */ + async function resolveConflict(): Promise<"retry" | "reloaded"> { + const [currentProject, serverBalance, currentHistory] = await Promise.all([ + getProject(projectId), + getEstimateBalance(projectId, balanceId), + getProjectHistory(projectId).catch(() => null), + ]); + setProject(currentProject); + setHistory(currentHistory); + revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision; + if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) { + return "retry"; + } + applyServerBalance(serverBalance); + setNotice(CONFLICT_MESSAGE); + void reloadComparison(); + return "reloaded"; + } + + const run = (async () => { + setSaveStatus("saving"); + for (let attempt = 0; ; attempt += 1) { + try { + const result = await updateEstimateBalance( + projectId, + body, + attempt === 0 ? revision : revisionRef.current ?? revision + ); + if (!("unchanged" in result)) { + revisionRef.current = result.history.currentRevision; + setHistory(result.history); + setProject((currentProject) => + currentProject + ? { ...currentProject, currentRevision: result.history.currentRevision } + : currentProject + ); + } + // The sent document is the saved state; the local draft is kept so + // typing is never interrupted by the server's copy. + savedFingerprintRef.current = fingerprint; + setSaveError(null); + const latest = draftRef.current; + if (latest && estimateBalanceFingerprint(latest) !== fingerprint) { + setSaveStatus("pending"); + } else { + setSaveStatus("saved"); + } + void reloadComparison(); + return true; + } catch (err) { + if (isRevisionConflictError(err)) { + try { + if (attempt === 0 && (await resolveConflict()) === "retry") { + continue; + } + if (attempt > 0) { + await reloadAll(); + setNotice(CONFLICT_MESSAGE); + } + } catch (reloadError) { + handleReloadError(reloadError); + } + return false; + } + if (isNotFoundError(err)) { + handleReloadError(err); + return false; + } + setSaveStatus("error"); + setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden.")); + return false; + } + } + })(); + saveInFlightRef.current = run; + try { + return await run; + } finally { + saveInFlightRef.current = null; + } + }, [ + applyServerBalance, + balanceId, + clearSaveTimer, + handleReloadError, + projectId, + reloadAll, + reloadComparison, + ]); + + // Debounced autosave after every change of the draft. + useEffect(() => { + if (!draft) { + return; + } + if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) { + return; + } + setSaveStatus((status) => (status === "saving" ? status : "pending")); + clearSaveTimer(); + saveTimerRef.current = setTimeout(() => { + saveTimerRef.current = null; + void saveNow(); + }, AUTOSAVE_DELAY_MS); + return clearSaveTimer; + }, [clearSaveTimer, draft, saveNow]); + + // Save immediately when the tab is hidden; on unload/unmount send the last + // change with keepalive so it survives the page. + useEffect(() => { + function hasUnsavedDraft() { + const current = draftRef.current; + return Boolean( + current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current + ); + } + function sendKeepalive(): boolean { + const current = draftRef.current; + const revision = revisionRef.current; + if ( + !current || + revision === null || + saveInFlightRef.current || + validateEstimateBalance(current).length + ) { + return false; + } + savedFingerprintRef.current = estimateBalanceFingerprint(current); + void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, { + keepalive: true, + }).catch(() => undefined); + return true; + } + function handleVisibilityChange() { + if (document.visibilityState === "hidden" && hasUnsavedDraft()) { + void saveNow(); + } + } + function handleBeforeUnload(event: BeforeUnloadEvent) { + if (!hasUnsavedDraft() || sendKeepalive()) { + return; + } + // A save is still running or the draft is invalid: let the browser ask. + event.preventDefault(); + event.returnValue = ""; + } + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("beforeunload", handleBeforeUnload); + // In-app navigation (e.g. sidebar) unmounts the editor. + if (hasUnsavedDraft()) { + sendKeepalive(); + } + }; + }, [projectId, saveNow]); + + const handleChange: EstimateBalanceUpdate = useCallback((update) => { + setNotice(null); + setDraft((current) => (current ? update(current) : current)); + }, []); + + const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => { + setInvalidFieldKeys((current) => { + if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) { + return current; + } + const next = new Set(current); + if (isValid) { + next.delete(fieldKey); + } else { + next.add(fieldKey); + } + return next; + }); + }, []); + + async function handleHistory(direction: "undo" | "redo") { + setError(null); + setNotice(null); + setIsHistoryBusy(true); + try { + // Pending edits are saved first so they become their own undo step. + await saveNow(); + const revision = revisionRef.current; + if (revision === null) { + return; + } + const result = + direction === "undo" + ? await undoProjectCommand(projectId, revision) + : await redoProjectCommand(projectId, revision); + revisionRef.current = result.history.currentRevision; + setHistory(result.history); + await reloadAll(); + } catch (err) { + if (isRevisionConflictError(err)) { + await reloadAll().catch(handleReloadError); + setNotice(CONFLICT_MESSAGE); + } else if (isNotFoundError(err)) { + handleReloadError(err); + } else { + setError( + errorMessage( + err, + direction === "undo" + ? "Die letzte Änderung konnte nicht rückgängig gemacht werden." + : "Die letzte Änderung konnte nicht wiederholt werden." + ) + ); + } + } finally { + setIsHistoryBusy(false); + } + } + + async function handleBackClick(event: MouseEvent) { + const current = draftRef.current; + if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) { + return; + } + event.preventDefault(); + await saveNow(); + router.push(`/projects/${projectId}/grobbilanz`); + } + + function handleSaveGroup(values: EstimateGroupFormValues) { + const editingGroup = groupDialog?.group ?? null; + handleChange((current) => + editingGroup + ? updateEstimateGroup(current, editingGroup.id, values) + : addEstimateGroup(current, values).balance + ); + setGroupDialog(null); + } + + function handleSelectGzf(value: number) { + if (gzfTarget) { + handleChange((current) => + updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, { + simultaneityFactor: value, + }) + ); + } + setGzfTarget(null); + } + + const isEditingDisabled = !draft || isHistoryBusy; + const invalidCount = invalidFieldKeys.size; + const listHref = `/projects/${projectId}/grobbilanz`; + + return ( +
+ {project && draft ? : null} +
+
+ void handleBackClick(event)} + title="Zurück zur Übersicht der Stände" + > +
+
+ void saveNow()} + status={saveStatus} + /> +
+ + +
+ +
+
+ + {notice ? ( +
+ {notice} +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

+ Grobe Leistungsbilanz wird geladen … +

+ ) : null} + {isMissing ? ( +
+ Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen + rückgängig gemacht wurde.{" "} + Zur Übersicht der Stände +
+ ) : null} + + {draft && project ? ( + <> +
+
+
+
+ Projekt + {project.name} +
+ + + +
+ setGroupDialog({ group: null })} + onChange={handleChange} + onEditGroup={(group) => setGroupDialog({ group })} + onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })} + onValidityChange={handleValidityChange} + threePhaseVoltageV={project.threePhaseVoltageV} + /> +
+
+ +
+
+ + {isComparisonOpen ? ( + + ) : null} +
+ {isComparisonOpen ? ( +
+ {comparisonError ? ( +
+ {comparisonError} +
+ ) : null} + {comparison ? : null} +

+ Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl × + Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne + Verteiler-GZF. +

+
+ ) : null} +
+ + ) : null} + + {groupDialog ? ( + setGroupDialog(null)} + onSave={handleSaveGroup} + /> + ) : null} + {gzfTarget ? ( + setGzfTarget(null)} onSelect={handleSelectGzf} /> + ) : null} +
+ ); +} + +function SaveStatusBadge({ + error, + invalidCount, + onRetry, + status, +}: { + error: string | null; + invalidCount: number; + onRetry: () => void; + status: SaveStatus; +}) { + const label = + status === "saving" + ? "Wird gespeichert …" + : status === "pending" + ? "Ungespeicherte Änderungen" + : status === "error" + ? "Nicht gespeichert – Fehler" + : status === "invalid" + ? "Nicht gespeichert – ungültige Eingaben" + : "Gespeichert"; + const tone = + status === "error" || status === "invalid" + ? "is-error" + : status === "saved" + ? "is-saved" + : "is-busy"; + return ( +
+ + {label} + + {invalidCount ? ( + + {invalidCount === 1 + ? "1 ungültige Eingabe wird nicht gespeichert" + : `${invalidCount} ungültige Eingaben werden nicht gespeichert`} + + ) : null} + {status === "error" ? ( + + ) : null} +
+ ); +} diff --git a/src/app/projects/[projectId]/grobbilanz/page.tsx b/src/app/projects/[projectId]/grobbilanz/page.tsx new file mode 100644 index 0000000..4b2cf81 --- /dev/null +++ b/src/app/projects/[projectId]/grobbilanz/page.tsx @@ -0,0 +1,292 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react"; +import { + createEstimateBalance, + deleteEstimateBalance, + getProject, + listEstimateBalances, +} from "../../../../frontend/utils/api"; +import type { + CreateEstimateBalanceInput, + EstimateBalanceDto, + ProjectCommandResultDto, + ProjectDto, +} from "../../../../frontend/types"; +import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance"; +import { + EstimateBalanceCreateModal, + type EstimateBalanceCreateDefaults, +} from "../../../../frontend/estimate-balance/estimate-balance-create-modal"; +import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document"; +import { + formatIsoDateGerman, + formatKw, +} from "../../../../frontend/estimate-balance/estimate-balance-format"; + +function countRows(balance: EstimateBalanceDto) { + return balance.groups.reduce((sum, group) => sum + group.rows.length, 0); +} + +export default function EstimateBalanceListPage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const router = useRouter(); + const [project, setProject] = useState(null); + const [balances, setBalances] = useState([]); + const [createDefaults, setCreateDefaults] = + useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const reload = useCallback(async () => { + const [currentProject, loadedBalances] = await Promise.all([ + getProject(projectId), + listEstimateBalances(projectId), + ]); + setProject(currentProject); + setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder)); + }, [projectId]); + + useEffect(() => { + if (!projectId) { + return; + } + setIsLoading(true); + reload() + .then(() => setError(null)) + .catch((err: unknown) => + setError( + err instanceof Error + ? err.message + : "Grobe Leistungsbilanzen konnten nicht geladen werden." + ) + ) + .finally(() => setIsLoading(false)); + }, [projectId, reload]); + + async function runCommand( + execute: (expectedRevision: number) => Promise, + fallbackMessage: string + ): Promise { + if (!project) { + return null; + } + setIsSaving(true); + setError(null); + try { + const result = await execute(project.currentRevision); + setProject((current) => + current ? { ...current, currentRevision: result.history.currentRevision } : current + ); + await reload(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : fallbackMessage); + // Refresh revision and list so a stale state does not block the next + // attempt; the command error stays visible. + await reload().catch(() => undefined); + return null; + } finally { + setIsSaving(false); + } + } + + async function handleCreate(input: CreateEstimateBalanceInput) { + const result = await runCommand( + (expectedRevision) => createEstimateBalance(projectId, input, expectedRevision), + "Stand konnte nicht angelegt werden." + ); + if (result) { + setCreateDefaults(null); + router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`); + } + } + + async function handleDelete(balance: EstimateBalanceDto) { + if ( + !window.confirm( + `Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?` + ) + ) { + return; + } + await runCommand( + (expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision), + "Stand konnte nicht gelöscht werden." + ); + } + + const isBusy = isSaving || !project; + const voltage = project?.threePhaseVoltageV ?? 400; + + return ( +
+
+
+ +
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

+ Grobe Leistungsbilanzen werden geladen … +

+ ) : null} + +
+
+

+ Stände + {balances.length} +

+
+ {balances.length ? ( +
+ + + + + + + + + + + + + + + {balances.map((balance) => { + const result = calculateEstimateBalance(balance, voltage); + const href = `/projects/${projectId}/grobbilanz/${balance.id}`; + const rowCount = countRows(balance); + return ( + + + + + + + + + + + ); + })} + +
StandLPHDatumAV kWAV kVASV kWSV kVAAktionen
+ + {balance.name} + +
+ {balance.groups.length} Kostengruppen ·{" "} + {rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`} +
+
{balance.workStage ?? "–"}{formatIsoDateGerman(balance.dateIso)} + {formatKw(result.withReserve.av.kw)} + + {formatKw(result.withReserve.av.kva)} + + {formatKw(result.withReserve.sv.kw)} + + {formatKw(result.withReserve.sv.kva)} + +
+ +
+
+

+ Leistungen jeweils inklusive Reserve. +

+
+ ) : !isLoading ? ( +
+

+ Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf + Basis der Vorlage KG 400 anlegen. +

+
+ ) : null} +
+ + {createDefaults ? ( + setCreateDefaults(null)} + onCreate={(input) => void handleCreate(input)} + /> + ) : null} +
+ ); +} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index fa342ea..ed03cd9 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -3,22 +3,61 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; +import { + ArrowLeft, + FileUp, + Library, + Link2, + ListTree, + Pencil, + Plus, + RefreshCw, + Settings, + Trash2, + Unlink, + X, +} from "lucide-react"; +import { IconButton } from "../../../frontend/components/icon-button"; +import { IconLink } from "../../../frontend/components/icon-link"; +import { + CollapsibleFilterPanel, + FilterSearchField, +} from "../../../frontend/components/filter-panel"; +import { MultiSelectDropdown } from "../../../frontend/components/multi-select-dropdown"; +import { + circuitGroupCategories, + circuitGroupCategoryLabels, +} from "../../../shared/constants/circuit-group"; +import { distributionBoardSupplyTypes } from "../../../shared/constants/distribution-board"; +import { + buildCostGroupFilterOptions, + buildFloorFilterOptions, + buildSupplyTypeFilterOptions, + emptyDistributionBoardFilters, + emptyProjectDeviceFilters, + filterDistributionBoards, + filterProjectDevices, + isDistributionBoardFilterActive, + isProjectDeviceFilterActive, + retainAvailableFilterValues, + type DistributionBoardFilters, + type FilterOption, + type ProjectDeviceFilters, +} from "../../../frontend/utils/overview-filters"; import { copyGlobalDeviceToProject, copyDistributionBoard, copyProjectDeviceToGlobal, createDistributionBoard, - createFloor, createProjectDevice, createRoom, - deleteFloor, deleteProjectDevice, - deleteRoom, deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, getProject, getProjectDeviceSyncPreview, + listBuildings, listCircuitLists, listDistributionBoards, listFloors, @@ -28,12 +67,11 @@ import { importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, - updateFloor, updateProjectDevice, updateProjectSettings, - updateRoom, } from "../../../frontend/utils/api"; import type { + BuildingDto, CircuitListDto, CreateRoomInput, CreateProjectDeviceInput, @@ -54,6 +92,13 @@ import { type DistributionBoardSupplyType, } from "../../../shared/constants/distribution-board"; import { ProjectVersionHistory } from "../../../frontend/components/project-version-history"; +import { + DistributionBoardInfoCard, + fromDistributionBoardInfoDraft, + toDistributionBoardInfoDraft, + type DistributionBoardInfoDraft, +} from "../../../frontend/components/distribution-board-info-card"; +import { Printer } from "lucide-react"; import { ProjectSettingsModal, type ProjectSettingsInput, @@ -61,7 +106,7 @@ import { import { FormModal } from "../../../frontend/components/form-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; -import { RoomModal } from "../../../frontend/components/room-modal"; +import { FloorSelectOptions } from "../../../frontend/components/floor-select-options"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -77,12 +122,36 @@ const projectDeviceSyncFieldLabels: Record = { remark: "Bemerkung", }; +const projectDeviceCategoryFilterOptions: FilterOption[] = + circuitGroupCategories.map((category) => ({ + id: category, + label: circuitGroupCategoryLabels[category], + })); + +const projectDevicePhaseTypeLabels: Record = { + single_phase: "1-phasig", + three_phase: "3-phasig", +}; + +const projectDevicePhaseTypeFilterOptions: FilterOption[] = ( + ["single_phase", "three_phase"] as const +).map((phaseType) => ({ + id: phaseType, + label: projectDevicePhaseTypeLabels[phaseType], +})); + +const boardFilterCollapsedStorageKey = + "leistungsbilanz:overview-filter-collapsed:distribution-boards"; +const projectDeviceFilterCollapsedStorageKey = + "leistungsbilanz:overview-filter-collapsed:project-devices"; + export default function ProjectDetailPage() { const params = useParams<{ projectId: string }>(); const [projectId, setProjectId] = useState(""); const [project, setProject] = useState(null); const [boards, setBoards] = useState([]); const [circuitLists, setCircuitLists] = useState([]); + const [buildings, setBuildings] = useState([]); const [floors, setFloors] = useState([]); const [rooms, setRooms] = useState([]); const [projectDevices, setProjectDevices] = useState([]); @@ -101,19 +170,27 @@ export default function ProjectDetailPage() { setEditingBoardSimultaneityFactor, ] = useState("1"); const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); - const [floorName, setFloorName] = useState(""); - const [editingFloor, setEditingFloor] = useState(null); - const [editingRoom, setEditingRoom] = useState(null); + const [editingBoardInfo, setEditingBoardInfo] = + useState( + toDistributionBoardInfoDraft({ + boardNumber: null, + location: null, + fedFrom: null, + remark: null, + }) + ); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); - const [structureModal, setStructureModal] = useState< - "board" | "floor" | "room" | null - >(null); + const [structureModal, setStructureModal] = useState<"board" | null>(null); const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = useState(false); const [editingProjectDevice, setEditingProjectDevice] = useState(null); - const [projectDeviceQuery, setProjectDeviceQuery] = useState(""); + const [boardFilters, setBoardFilters] = useState( + emptyDistributionBoardFilters + ); + const [projectDeviceFilters, setProjectDeviceFilters] = + useState(emptyProjectDeviceFilters); const [syncPreview, setSyncPreview] = useState(null); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); @@ -132,6 +209,7 @@ export default function ProjectDetailPage() { getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), + listBuildings(projectId), listFloors(projectId), listRooms(projectId), listProjectDevices(projectId), @@ -141,6 +219,7 @@ export default function ProjectDetailPage() { currentProject, distributionBoards, loadedCircuitLists, + loadedBuildings, loadedFloors, loadedRooms, loadedProjectDevices, @@ -149,6 +228,7 @@ export default function ProjectDetailPage() { setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); + setBuildings(loadedBuildings); setFloors(loadedFloors); setRooms(loadedRooms); setProjectDevices(loadedProjectDevices); @@ -161,25 +241,71 @@ export default function ProjectDetailPage() { }, [projectId]); const boardCount = useMemo(() => boards.length, [boards.length]); + const buildingCount = useMemo(() => buildings.length, [buildings.length]); const floorCount = useMemo(() => floors.length, [floors.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); - const visibleProjectDevices = useMemo(() => { - const query = projectDeviceQuery.trim().toLocaleLowerCase("de"); - if (!query) { - return projectDevices; - } - return projectDevices.filter((device) => - [ - device.name, - device.displayName, - device.category, - device.connectionKind, - device.costGroup, - ].some((value) => value?.toLocaleLowerCase("de").includes(query)) - ); - }, [projectDeviceQuery, projectDevices]); + const costGroupFilterOptions = useMemo( + () => buildCostGroupFilterOptions(projectDevices), + [projectDevices] + ); + const effectiveProjectDeviceFilters = useMemo( + () => ({ + ...projectDeviceFilters, + costGroups: retainAvailableFilterValues( + projectDeviceFilters.costGroups, + costGroupFilterOptions + ), + }), + [costGroupFilterOptions, projectDeviceFilters] + ); + const visibleProjectDevices = useMemo( + () => + filterProjectDevices( + projectDevices, + effectiveProjectDeviceFilters, + circuitGroupCategoryLabels + ), + [effectiveProjectDeviceFilters, projectDevices] + ); const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]); + const floorFilterOptions = useMemo( + () => buildFloorFilterOptions(floors), + [floors] + ); + const supplyTypeFilterOptions = useMemo( + () => + buildSupplyTypeFilterOptions( + distributionBoardSupplyTypes, + project?.enabledDistributionBoardSupplyTypes ?? [], + boards, + distributionBoardSupplyTypeLabels + ), + [boards, project?.enabledDistributionBoardSupplyTypes] + ); + const effectiveBoardFilters = useMemo( + () => ({ + ...boardFilters, + floorIds: retainAvailableFilterValues( + boardFilters.floorIds, + floorFilterOptions + ), + supplyTypes: retainAvailableFilterValues( + boardFilters.supplyTypes, + supplyTypeFilterOptions + ), + }), + [boardFilters, floorFilterOptions, supplyTypeFilterOptions] + ); + const visibleBoards = useMemo( + () => + filterDistributionBoards( + boards, + effectiveBoardFilters, + new Map(floors.map((floor) => [floor.id, floor.name])) + ), + [boards, effectiveBoardFilters, floors] + ); const circuitListByBoardId = useMemo( () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), [circuitLists] @@ -245,74 +371,6 @@ export default function ProjectDetailPage() { } } - async function handleSaveFloor(event: FormEvent) { - event.preventDefault(); - if (!projectId || !project || !floorName.trim()) { - return; - } - setIsSaving(true); - setError(null); - try { - const result = editingFloor - ? await updateFloor( - projectId, - editingFloor.id, - { name: floorName.trim() }, - project.currentRevision - ) - : await createFloor( - projectId, - { name: floorName.trim() }, - project.currentRevision - ); - setFloors((current) => - editingFloor - ? current.map((floor) => - floor.id === result.floor.id ? result.floor : floor - ) - : [...current, result.floor] - ); - applyProjectRevision(result.history.currentRevision); - setFloorName(""); - setEditingFloor(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - - async function handleSaveRoom(input: CreateRoomInput) { - if (!projectId || !project) return; - setIsSaving(true); - setError(null); - try { - const result = editingRoom - ? await updateRoom( - projectId, - editingRoom.id, - input, - project.currentRevision - ) - : await createRoom(projectId, input, project.currentRevision); - setRooms((current) => - editingRoom - ? current.map((room) => - room.id === result.room.id ? result.room : room - ) - : [...current, result.room] - ); - applyProjectRevision(result.history.currentRevision); - setEditingRoom(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - async function handleSaveProjectSettings(input: ProjectSettingsInput) { if (!projectId || !project) { return; @@ -363,6 +421,11 @@ export default function ProjectDetailPage() { if (!projectId || !project) { return; } + const device = projectDevices.find((item) => item.id === projectDeviceId); + const deviceName = device?.displayName || device?.name || "Projektgerät"; + if (!window.confirm(`Projektgerät „${deviceName}“ wirklich löschen?`)) { + return; + } setIsSaving(true); setError(null); try { @@ -424,6 +487,7 @@ export default function ProjectDetailPage() { String(board.simultaneityFactor) ); setEditingBoardCopyName(`${board.name} Kopie`); + setEditingBoardInfo(toDistributionBoardInfoDraft(board)); } function openBoardCreator() { @@ -459,6 +523,7 @@ export default function ProjectDetailPage() { floorId: editingBoardFloorId || null, supplyType: editingBoardSupplyType, simultaneityFactor, + ...fromDistributionBoardInfoDraft(editingBoardInfo), }, project.currentRevision ); @@ -498,65 +563,6 @@ export default function ProjectDetailPage() { return result; } - function openCreateFloor() { - setEditingFloor(null); - setFloorName(""); - setStructureModal("floor"); - } - - function openEditFloor(floor: FloorDto) { - setEditingFloor(floor); - setFloorName(floor.name); - setStructureModal("floor"); - } - - function openCreateRoom() { - setEditingRoom(null); - setStructureModal("room"); - } - - function openEditRoom(room: RoomDto) { - setEditingRoom(room); - setStructureModal("room"); - } - - async function handleDeleteFloor() { - if (!projectId || !project || !editingFloor) return; - if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return; - setIsSaving(true); - setError(null); - try { - const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision); - setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id)); - applyProjectRevision(result.history.currentRevision); - setEditingFloor(null); - setFloorName(""); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden."); - } finally { - setIsSaving(false); - } - } - - async function handleDeleteRoom() { - if (!projectId || !project || !editingRoom) return; - if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return; - setIsSaving(true); - setError(null); - try { - const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision); - setRooms((current) => current.filter((room) => room.id !== editingRoom.id)); - applyProjectRevision(result.history.currentRevision); - setEditingRoom(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden."); - } finally { - setIsSaving(false); - } - } - async function handleCopyBoard() { if ( !projectId || @@ -849,23 +855,37 @@ export default function ProjectDetailPage() {

+ +
@@ -878,14 +898,18 @@ export default function ProjectDetailPage() {
Verteilungen
{boardCount}
- + +
Gebäude
+
{buildingCount}
+ +
Etagen
{floorCount}
-
- + +
Räume
{roomCount}
-
+
Projektgeräte
{projectDeviceCount}
@@ -910,19 +934,59 @@ export default function ProjectDetailPage() { Verteilungen {boardCount}
- +
+ +
+ setBoardFilters(emptyDistributionBoardFilters)} + storageKey={boardFilterCollapsedStorageKey} + totalCount={boards.length} + visibleCount={visibleBoards.length} + > + + setBoardFilters((current) => ({ ...current, query })) + } + placeholder="Verteilungen durchsuchen …" + value={boardFilters.query} + /> + + setBoardFilters((current) => ({ ...current, floorIds })) + } + options={floorFilterOptions} + selected={effectiveBoardFilters.floorIds} + /> + + setBoardFilters((current) => ({ ...current, supplyTypes })) + } + options={supplyTypeFilterOptions} + selected={effectiveBoardFilters.supplyTypes} + /> +
+ + @@ -930,11 +994,15 @@ export default function ProjectDetailPage() { - {boards.map((board) => { + {visibleBoards.map((board) => { const circuitList = circuitListByBoardId.get(board.id); return ( + + - ) : null}
Nr. VerteilungÖrtlichkeit Etage Netzart GZF
+ {board.boardNumber ?? "–"} + {board.name}{board.location ?? "–"} {board.floorId ? floorById.get(board.floorId)?.name ?? "Unbekannt" @@ -957,22 +1025,21 @@ export default function ProjectDetailPage() { )} -
- + /> {circuitList ? ( - - Stromkreisliste öffnen - + icon={ListTree} + label="Stromkreisliste öffnen" + showLabel + variant="primary" + /> ) : ( Keine Stromkreisliste vorhanden @@ -985,95 +1052,18 @@ export default function ProjectDetailPage() { })} {!boards.length ? (
+ Noch keine Verteilungen vorhanden.
-
- - - -
-
-
-
- Etagen - {floorCount} -
- -
-
    - {floors.map((floor) => ( -
  • - {floor.name} - -
  • - ))} - {!floors.length ? ( -
  • Noch keine Etagen vorhanden.
  • + {boards.length > 0 && visibleBoards.length === 0 ? ( +

    + Keine Verteilungen entsprechen den Filtern. +

    ) : null} -
-
-
- -
-
-
-
- Räume - {roomCount} -
- -
-
- - - - - - - - - - - {rooms.map((room) => ( - - - - - - - ))} - {!rooms.length ? ( - - - - ) : null} - -
RaumnummerRaumnameEtageAktionen
{room.roomNumber}{room.roomName}{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"} - -
- Noch keine Räume vorhanden. -
@@ -1085,24 +1075,58 @@ export default function ProjectDetailPage() { Projektgeräte {projectDeviceCount} -
- setProjectDeviceQuery(event.target.value)} - placeholder="Geräte durchsuchen" - type="search" - value={projectDeviceQuery} - /> - + showLabel + variant="primary" + />
+ setProjectDeviceFilters(emptyProjectDeviceFilters)} + storageKey={projectDeviceFilterCollapsedStorageKey} + totalCount={projectDevices.length} + visibleCount={visibleProjectDevices.length} + > + + setProjectDeviceFilters((current) => ({ ...current, query })) + } + placeholder="Gerät, Anschlussart, KG …" + value={projectDeviceFilters.query} + /> + + setProjectDeviceFilters((current) => ({ ...current, categories })) + } + options={projectDeviceCategoryFilterOptions} + selected={effectiveProjectDeviceFilters.categories} + /> + + setProjectDeviceFilters((current) => ({ ...current, phaseTypes })) + } + options={projectDevicePhaseTypeFilterOptions} + selected={effectiveProjectDeviceFilters.phaseTypes} + /> + + setProjectDeviceFilters((current) => ({ ...current, costGroups })) + } + options={costGroupFilterOptions} + selected={effectiveProjectDeviceFilters.costGroups} + /> +
@@ -1121,11 +1145,13 @@ export default function ProjectDetailPage() { {device.displayName}
{device.name} - {device.category ? ` · ${device.category}` : ""} + {device.category + ? ` · ${circuitGroupCategoryLabels[device.category] ?? device.category}` + : ""}
@@ -1182,7 +1201,7 @@ export default function ProjectDetailPage() { @@ -1199,9 +1218,13 @@ export default function ProjectDetailPage() { Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.

- +
+ setSyncPreview(null)} + /> +
@@ -1280,23 +1303,23 @@ export default function ProjectDetailPage() {
- {device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"} + {projectDevicePhaseTypeLabels[device.phaseType]} {device.connectionKind ? (
{device.connectionKind} @@ -1141,39 +1167,32 @@ export default function ProjectDetailPage() {
{device.costGroup ?? "-"} -
- - - - + icon={Trash2} + label="Löschen" + onClick={() => void handleDeleteProjectDevice(device.id)} + variant="danger" + />
{projectDevices.length - ? "Keine Projektgeräte entsprechen der Suche." + ? "Keine Projektgeräte entsprechen den Filtern." : "Noch keine Projektgeräte vorhanden."}
-
- - + icon={Unlink} + label="Auswahl trennen" + onClick={() => void handleDisconnectProjectDeviceRows()} + showLabel + variant="danger" + />
) : null} @@ -1336,11 +1359,7 @@ export default function ProjectDetailPage() { value={boardFloorId} > - {floors.map((floor) => ( - - ))} +
@@ -1406,11 +1425,7 @@ export default function ProjectDetailPage() { value={editingBoardFloorId} > - {floors.map((floor) => ( - - ))} +
@@ -1464,6 +1479,12 @@ export default function ProjectDetailPage() {
+
+ +

) : null} - {structureModal === "floor" ? ( - { - setStructureModal(null); - setEditingFloor(null); - }} - onSubmit={handleSaveFloor} - submitDisabled={!floorName.trim()} - submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"} - title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"} - > - - setFloorName(event.target.value)} - placeholder="z. B. EG" - required - value={floorName} - /> - {editingFloor ? ( -
-

- Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden. -

- -
- ) : null} -
- ) : null} - - {structureModal === "room" ? ( - { - setStructureModal(null); - setEditingRoom(null); - }} - onDelete={editingRoom ? () => void handleDeleteRoom() : undefined} - onSave={handleSaveRoom} - /> - ) : null} - {isProjectDeviceModalOpen ? ( \r\n]/g, " ")); +} + +function pageRules(project: ProjectDto, stand: string, format: PlanFormat) { + const marginBoxes = ` + @bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } + @bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } + @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }`; + return ` + @page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} } + @page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`; +} + +export default function ProjectPrintPage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const [data, setData] = useState(null); + const [logo, setLogo] = useState(null); + const [office, setOffice] = useState({ + officeName: null, + officeAddress: null, + }); + const [officeDraft, setOfficeDraft] = useState<{ + officeName: string; + officeAddress: string; + } | null>(null); + const [format, setFormat] = useState("a4"); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [isBusy, setIsBusy] = useState(false); + const [printedAt] = useState(() => new Date()); + + useEffect(() => { + if (!projectId) { + return; + } + let cancelled = false; + async function load() { + const [project, boards, circuitLists, floors, snapshots, branding] = + await Promise.all([ + getProject(projectId), + listDistributionBoards(projectId), + listCircuitLists(projectId), + listFloors(projectId), + listProjectSnapshots(projectId), + getDocumentBranding(), + ]); + const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name])); + const summaries = await Promise.all( + [...boards] + .sort( + (left, right) => + boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") || + boardSortCollator.compare(left.name, right.name) + ) + .flatMap((board) => { + const circuitList = circuitLists.find( + (list) => list.distributionBoardId === board.id + ); + return circuitList ? [{ board, circuitList }] : []; + }) + .map(async ({ board, circuitList }) => + summarizePrintBoard({ + board, + circuitListId: circuitList.id, + tree: await getCircuitTree(projectId, circuitList.id), + floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null, + }) + ) + ); + const finalProject = await getProject(projectId); + assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision); + if (cancelled) { + return; + } + setData({ + project, + summaries, + namedSnapshots: snapshots + .filter((snapshot) => snapshot.kind === "named") + .sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)), + }); + setLogo(branding.logo); + setOffice(branding.office); + document.title = `Leistungsbilanz – ${project.name}`; + } + load().catch((err: unknown) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden."); + } + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + const entries = useMemo( + () => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []), + [data] + ); + const stand = useMemo( + () => + new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(printedAt), + [printedAt] + ); + const logoSrc = logo + ? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}` + : null; + + async function handleLogoUpload(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) { + return; + } + if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) { + setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen."); + return; + } + if (file.size > maxDocumentLogoBytes) { + setError("Das Logo darf höchstens 2 MB groß sein."); + return; + } + setIsBusy(true); + setError(null); + try { + const result = await uploadDocumentLogo(file); + setLogo(result.logo); + setMessage("Logo hinterlegt. Es gilt für alle Projekte."); + } catch (err) { + setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden."); + } finally { + setIsBusy(false); + } + } + + async function handleOfficeSave(event: FormEvent) { + event.preventDefault(); + if (!officeDraft) { + return; + } + setIsBusy(true); + setError(null); + try { + const result = await updateOfficeProfile({ + officeName: officeDraft.officeName.trim() || null, + officeAddress: officeDraft.officeAddress.trim() || null, + }); + setOffice(result.office); + setOfficeDraft(null); + setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte."); + } catch (err) { + setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden."); + } finally { + setIsBusy(false); + } + } + + async function handleLogoDelete() { + if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) { + return; + } + setIsBusy(true); + setError(null); + try { + await deleteDocumentLogo(); + setLogo(null); + setMessage("Logo entfernt."); + } catch (err) { + setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden."); + } finally { + setIsBusy(false); + } + } + + return ( + // Paper output: always light, independent of the app's dark mode. +
+ {data ? : null} +
+ + Seitenansicht{data ? ` · ${data.project.name}` : ""} + + {message ? {message} : null} + {error ? {error} : null} + + + + {logo ? ( + + ) : null} + + + Zum Projekt + +
+ {officeDraft ? ( + setOfficeDraft(null)} + onSubmit={handleOfficeSave} + submitLabel="Büroangaben speichern" + title="Büroangaben" + > +
+
+ + + setOfficeDraft({ ...officeDraft, officeName: event.target.value }) + } + value={officeDraft.officeName} + /> +
+
+ +