Compare commits
No commits in common. "5cbbd3a84aaf56e9ef998448b85d2012bac20765" and "49488f4e1b3e1e242a0b8d28bf96f5945ed726b4" have entirely different histories.
5cbbd3a84a
...
49488f4e1b
140 changed files with 1911 additions and 27442 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ dist/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
|
dynamo/output/
|
||||||
|
|
|
||||||
137
AGENTS.md
137
AGENTS.md
|
|
@ -164,9 +164,6 @@ Keyboard behavior:
|
||||||
- Escape cancels
|
- Escape cancels
|
||||||
- Tab / Shift+Tab navigates editable cells
|
- Tab / Shift+Tab navigates editable cells
|
||||||
- Arrow keys navigate cells when not editing
|
- 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.
|
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
|
||||||
|
|
||||||
|
|
@ -180,37 +177,17 @@ 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.
|
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
|
Header labels break only between words and put a trailing unit such as
|
||||||
`[kW]` on a second line.
|
`[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 / Styling Rule
|
||||||
|
|
||||||
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
|
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.
|
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.
|
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.
|
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
|
## Drag-and-Drop Rules
|
||||||
|
|
||||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||||
|
|
@ -284,16 +261,8 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 7 additionally stores the project buildings
|
Current snapshot schema version 5 additionally stores the manual quantity share
|
||||||
and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6
|
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
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
|
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||||
external-model source, import batches, room mappings and objects. Version 3
|
external-model source, import batches, room mappings and objects. Version 3
|
||||||
snapshots are upgraded with an empty external-model state; version 2 snapshots
|
snapshots are upgraded with an empty external-model state; version 2 snapshots
|
||||||
|
|
@ -307,7 +276,7 @@ project graph.
|
||||||
Project-wide Revit CSV configuration uses
|
Project-wide Revit CSV configuration uses
|
||||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||||
persisted through the shared project-command transaction, so create, update,
|
persisted through the shared project-command transaction, so create, update,
|
||||||
delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable
|
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
|
||||||
project duplication preserve the configuration; duplication remaps its internal
|
project duplication preserve the configuration; duplication remaps its internal
|
||||||
id and project link.
|
id and project link.
|
||||||
The project page exposes the Revit CSV modal. It edits the complete
|
The project page exposes the Revit CSV modal. It edits the complete
|
||||||
|
|
@ -321,7 +290,7 @@ immutable import batches with original bytes plus the classified cell matrix,
|
||||||
normalized source-room mappings and external objects unique by
|
normalized source-room mappings and external objects unique by
|
||||||
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
|
||||||
The general external-model repository is read-only; runtime writes belong only
|
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/6
|
to typed command adapters. Snapshot/restore and portable project transfer schema version 5
|
||||||
capture the complete external state and remap every internal UUID and link while
|
capture the complete external state and remap every internal UUID and link while
|
||||||
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
||||||
Import batches additionally store the monotonic project CSV configuration
|
Import batches additionally store the monotonic project CSV configuration
|
||||||
|
|
@ -410,10 +379,6 @@ Project settings use the persistent `project.update-settings` command. Project
|
||||||
metadata, the public-building classification, both voltage defaults and the
|
metadata, the public-building classification, both voltage defaults and the
|
||||||
enabled distribution-board supply types change in one revision and Undo/Redo
|
enabled distribution-board supply types change in one revision and Undo/Redo
|
||||||
restores them together; the project PUT route requires `expectedRevision`.
|
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
|
The public-building flag is the future sizing input for requiring halogen-free
|
||||||
cables and lines. The system catalog is `AV`, `SV`, `EV`,
|
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
|
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||||
|
|
@ -468,18 +433,6 @@ the only supported circuit-protection source; no flat compatibility fields remai
|
||||||
Distribution-board floor assignment and a project-enabled supply type use
|
Distribution-board floor assignment and a project-enabled supply type use
|
||||||
`distribution-board.update`; both values are snapshot/export fields and one
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
persistent undo step.
|
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
|
Floor and room setup uses persistent insert, update and delete commands with
|
||||||
complete stable snapshots. Their inverses remove or restore only unchanged records and
|
complete stable snapshots. Their inverses remove or restore only unchanged records and
|
||||||
reject floors with assigned rooms/distribution boards or rooms referenced by
|
reject floors with assigned rooms/distribution boards or rooms referenced by
|
||||||
|
|
@ -501,49 +454,8 @@ Required operations:
|
||||||
- synchronization changes
|
- 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
|
|
||||||
`<thead>` 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
|
## 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
|
When explicitly working on Phase 14, use
|
||||||
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
|
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
|
||||||
truth together with the current architecture documents.
|
truth together with the current architecture documents.
|
||||||
|
|
@ -607,13 +519,10 @@ circuit list (see `docs/cable-sizing-module.md`). Rules:
|
||||||
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
|
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
|
||||||
- It has no database table and no migration; `/api/cable-sizing/*` is
|
- It has no database table and no migration; `/api/cable-sizing/*` is
|
||||||
stateless.
|
stateless.
|
||||||
- Current-carrying capacities and correction factors use
|
- Reference data is IEC 60364-5-52:2009 Annex B. Change table values only with
|
||||||
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the
|
a documented source in `docs/cable-sizing-module.md`; where sources differ,
|
||||||
separate basis of voltage-drop and protection checks are documented in
|
use the lower value. Inputs outside the tables return no result instead of
|
||||||
`docs/cable-sizing-module.md`. Inputs outside the supported tables return no
|
assuming a factor of 1.
|
||||||
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 φ,
|
- Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ,
|
||||||
length, protective device) and remain editable; manual cable entry without
|
length, protective device) and remain editable; manual cable entry without
|
||||||
a calculation stays possible.
|
a calculation stays possible.
|
||||||
|
|
@ -640,30 +549,6 @@ Future sizing will need:
|
||||||
|
|
||||||
Users must be able to override sizing suggestions.
|
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
|
## Persistence and Migration Rules
|
||||||
|
|
||||||
- SQLite is the currently supported database.
|
- SQLite is the currently supported database.
|
||||||
|
|
@ -681,15 +566,11 @@ for the early-phase power balance by DIN 276 cost group (see
|
||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never
|
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
|
||||||
Dynamo), except when Phase 14 is explicitly requested and
|
|
||||||
`docs/spec/revit-csv-integration-requirements.md` is being followed
|
`docs/spec/revit-csv-integration-requirements.md` is being followed
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
- supported production deployment
|
- 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.
|
Do not implement these while working on an unrelated phase.
|
||||||
|
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -4,14 +4,10 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||||
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
|
||||||
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
|
||||||
|
|
||||||
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
|
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
|
||||||
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
|
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
|
||||||
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
|
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
|
||||||
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
|
implementiert.
|
||||||
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
|
## Unterstützter Arbeitsablauf
|
||||||
|
|
||||||
|
|
@ -29,11 +25,6 @@ des Linear-Plugins in Revit.
|
||||||
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
|
||||||
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
|
||||||
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
|
- 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
|
## Technik
|
||||||
|
|
||||||
|
|
@ -151,7 +142,6 @@ vor der Baseline werden nicht unterstützt.
|
||||||
- [Aktuelle Architektur](docs/current-architecture.md)
|
- [Aktuelle Architektur](docs/current-architecture.md)
|
||||||
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
|
||||||
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.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)
|
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
|
||||||
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
|
||||||
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
- [Roadmap](docs/spec/07-implementation-phases-todo.md)
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,6 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
fachliches Modell und Grid-Aufteilung
|
fachliches Modell und Grid-Aufteilung
|
||||||
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
|
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
|
||||||
Drag-and-drop, Sortierung, Filterung und Undo/Redo
|
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
|
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
||||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||||
|
|
@ -33,8 +27,7 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
||||||
|
|
||||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||||
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.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)
|
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
|
||||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||||
|
|
@ -57,6 +50,3 @@ Limitierungen und Roadmap.
|
||||||
|
|
||||||
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
|
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
|
||||||
Arbeitsstand, keine aktuelle Implementierungsanweisung
|
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`
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,83 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,109 +1,159 @@
|
||||||
# Leitungsberechnung
|
# Leitungsberechnung (Cable-Sizing-Modul)
|
||||||
|
|
||||||
Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare
|
Optionales, abgegrenztes Rechenmodul für die Querschnittsempfehlung eines
|
||||||
Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die
|
Stromkreises. Es wurde als eigenständiges Modul vereinbart, das an die
|
||||||
ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`,
|
Stromkreisliste angedockt ist. Es **schreibt keine Projektdaten selbst**: Ein
|
||||||
`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`.
|
Ergebnis wird erst durch ausdrückliches „Übernehmen“ über den bestehenden
|
||||||
Revisionen sowie Undo/Redo bleiben dadurch erhalten.
|
Befehl `circuit.update` in den Stromkreis geschrieben. Revisionen und
|
||||||
|
Undo/Redo bleiben damit unverändert gültig.
|
||||||
|
|
||||||
Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil
|
Die vollständige, automatische Bemessung aller Stromkreise (siehe
|
||||||
des Moduls.
|
`docs/spec/06-future-sizing-and-calculations.md`) ist weiterhin **nicht**
|
||||||
|
Teil dieses Moduls.
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau und Andockpunkte
|
||||||
|
|
||||||
```text
|
```
|
||||||
src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen
|
src/cable-sizing/domain/cable-sizing-calculation.ts reine Berechnung + Tabellen
|
||||||
src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung
|
src/cable-sizing/domain/cable-sizing-contracts.ts Zod-Schema der API
|
||||||
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
|
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
|
||||||
src/server/routes/cable-sizing.routes.ts Router
|
src/server/routes/cable-sizing.routes.ts Router unter /api/cable-sizing
|
||||||
src/frontend/components/cable-sizing-api.ts Fetch-Client
|
src/frontend/components/cable-sizing-api.ts Fetch-Client
|
||||||
src/frontend/components/cable-sizing-defaults.ts Vorbelegung
|
src/frontend/components/cable-sizing-defaults.ts Vorbelegung aus dem Stromkreis
|
||||||
src/frontend/components/cable-sizing-modal.tsx Dialog
|
src/frontend/components/cable-sizing-modal.tsx Dialog
|
||||||
tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle
|
tests/cable-sizing-calculation.test.ts Tabellen, Rechnung, Vorbelegung
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist
|
Das Domänenmodul importiert nichts aus `db/`, `server/` oder `frontend/`.
|
||||||
zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die
|
Außerhalb dieser Dateien gibt es genau zwei Andockpunkte:
|
||||||
Kabelzellen behalten ihr übliches Spreadsheet-Verhalten.
|
|
||||||
|
|
||||||
## Eingaben
|
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.
|
||||||
|
|
||||||
Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes
|
Der Aufruf liegt bewusst **nicht** auf einer Tabellenzelle: Klick, Doppelklick,
|
||||||
cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der
|
Enter, F2 und Tippen in den Kabelspalten verhalten sich wie in jeder anderen
|
||||||
Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung,
|
Zelle (Spreadsheet-Regel in `AGENTS.md`). Es gibt keine eigene Datenbanktabelle
|
||||||
zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen.
|
und keine Migration.
|
||||||
|
|
||||||
Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf
|
## Eingaben und Vorbelegung
|
||||||
hin, dass dann kein Spannungsfall angesetzt wird.
|
|
||||||
|
|
||||||
## Normative Grundlage und Grenzen
|
| 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 |
|
||||||
|
|
||||||
Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen
|
Kabeltyp und Querschnitt lassen sich im Dialog auch ohne Berechnung direkt
|
||||||
**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich
|
eintragen. Weicht der Eintrag von der Empfehlung oder dem
|
||||||
**Berichtigung 1:2023-10** geprüft:
|
Praxis-Mindestquerschnitt ab, erscheint nur ein Hinweis.
|
||||||
|
|
||||||
- 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
|
## Rechenweg
|
||||||
|
|
||||||
1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl.
|
1. Betriebsstrom I_B aus Leistung, Spannung, cos φ und Phasenzahl.
|
||||||
2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen
|
2. Erforderliche Belastbarkeit: ohne Schutzorgan I_B; mit Schutzorgan
|
||||||
Schutzorgan.
|
max(I_B, I_n, I₂/1,45) nach IEC 60364-4-43 (I_n ≤ I_z und I₂ ≤ 1,45 · I_z).
|
||||||
3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls
|
I₂/I_n: Leitungsschutzschalter 1,45; Sicherung gG 2,1 (I_n ≤ 4 A),
|
||||||
Oberschwingungsfaktor.
|
1,9 (4 A < I_n < 16 A), 1,6 (I_n ≥ 16 A).
|
||||||
4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt.
|
3. Tabellenwert I_z je Querschnitt × Temperaturfaktor × Häufungsfaktor
|
||||||
5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall.
|
(× 0,86 bei 15–33 % 3. Harmonischer im Drehstromkreis).
|
||||||
6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen
|
4. Spannungsfall ΔU = k · L · I_B · cos φ / (κ · A · U) mit k = 2 (1~) bzw.
|
||||||
Mindestquerschnitt von 2,5 mm².
|
√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.
|
||||||
|
|
||||||
Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten
|
Temperaturen und Häufungen **zwischen** Tabellenwerten werden auf den nächsten
|
||||||
ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs
|
ungünstigeren Tabellenwert gerundet (höhere Temperatur, mehr Stromkreise).
|
||||||
wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin
|
Werte **außerhalb** der Tabellen liefern kein Ergebnis und eine Meldung – es
|
||||||
manuell eingetragen und bewusst übernommen werden.
|
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.
|
||||||
|
|
||||||
## Nicht enthalten
|
## Nicht enthalten
|
||||||
|
|
||||||
- direkte Erdverlegung
|
- Kurzschlussfestigkeit, Abschaltbedingungen (Schleifenimpedanz) und
|
||||||
- detaillierte Anordnung mehrerer Kabel bei Verlegeart E
|
Selektivität
|
||||||
- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer
|
- Bodenwärmewiderstand ≠ 2,5 K·m/W, Häufung mit Abstand, Verlegearten F/G
|
||||||
- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität
|
- Bemessung des Neutralleiters bei > 33 % 3. Harmonischer (nur Hinweis)
|
||||||
- abweichender Bodenwärmewiderstand sowie Verlegearten F und G
|
- automatische Bemessung oder Prüfung aller Stromkreise
|
||||||
- automatische Berechnung oder Änderung mehrerer Stromkreise
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist
|
`POST /api/cable-sizing/calculate` – zustandslos, liest und schreibt keine
|
||||||
`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält
|
Projektdaten.
|
||||||
`supported: false` und einen der Gründe `temperature`, `grouping` oder
|
|
||||||
`harmonics`, wenn keine belastbare Empfehlung möglich ist.
|
|
||||||
|
|
||||||
`GET /api/cable-sizing/laying-methods` und
|
```jsonc
|
||||||
`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten.
|
{
|
||||||
|
"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.
|
||||||
|
|
|
||||||
|
|
@ -59,14 +59,6 @@ this value for optimistic concurrency checks.
|
||||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||||
the generic command endpoint
|
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
|
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||||
|
|
@ -82,9 +74,8 @@ Protected-board structure uses
|
||||||
persisted inverse of a delete. The dispatcher also supports the documented
|
persisted inverse of a delete. The dispatcher also supports the documented
|
||||||
project-device, project settings, distribution-board and project-location
|
project-device, project settings, distribution-board and project-location
|
||||||
commands. The generic editor commands listed here use schema version `1`.
|
commands. The generic editor commands listed here use schema version `1`.
|
||||||
`project.update-settings` uses version `3` (including the print cover fields)
|
`project.update-settings` uses version `2` and still accepts supported baseline
|
||||||
and still accepts version `2` and supported baseline version `1` commands
|
version `1` commands without changing the newer building classification.
|
||||||
without changing the newer cover fields or building classification.
|
|
||||||
Pre-baseline formats remain unsupported. Unsupported types or schema versions
|
Pre-baseline formats remain unsupported. Unsupported types or schema versions
|
||||||
are rejected. Insert commands contain the complete entity or circuit block
|
are rejected. Insert commands contain the complete entity or circuit block
|
||||||
with stable ids; delete commands include the expected parent identity. Circuit
|
with stable ids; delete commands include the expected parent identity. Circuit
|
||||||
|
|
@ -182,9 +173,6 @@ synchronizes linked circuit rows implicitly.
|
||||||
- `PUT /projects/:projectId`
|
- `PUT /projects/:projectId`
|
||||||
- request:
|
- request:
|
||||||
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
|
`{ "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
|
- executes `project.update-settings` as one atomic revision
|
||||||
- response:
|
- response:
|
||||||
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
|
|
@ -222,12 +210,6 @@ A stale `expectedRevision` returns HTTP `409` with
|
||||||
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||||
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
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
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|
@ -248,13 +230,9 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }`
|
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||||
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
- executes `distribution-board.update`; floor and supply type are restored
|
||||||
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
together by persistent Undo/Redo
|
||||||
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`
|
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
|
||||||
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
|
||||||
- duplicates the complete distribution-board subtree with new owning UUIDs
|
- duplicates the complete distribution-board subtree with new owning UUIDs
|
||||||
|
|
@ -271,27 +249,15 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or
|
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or
|
||||||
removes the same complete subtree, including protection devices.
|
removes the same complete subtree, including protection devices.
|
||||||
|
|
||||||
### Project Buildings, Floors and Rooms
|
### Project 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`
|
- `POST /projects/:projectId/floors`
|
||||||
- body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }`
|
- body: `{ "name": "EG", "expectedRevision": 13 }`
|
||||||
(`buildingId` optional, `null` = no building)
|
- executes `project-floor.insert` with a stable floor id
|
||||||
- executes `project-floor.insert` (command version 2) with a stable floor id
|
|
||||||
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- `PUT /projects/:projectId/floors/:floorId`
|
- `PUT /projects/:projectId/floors/:floorId`
|
||||||
- body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }`
|
- body: `{ "name": "1. OG", "expectedRevision": 14 }`
|
||||||
- executes an exact persistent floor update of name and/or building; a
|
- executes an exact persistent floor update
|
||||||
missing `buildingId` keeps the current building
|
|
||||||
- `DELETE /projects/:projectId/floors/:floorId`
|
- `DELETE /projects/:projectId/floors/:floorId`
|
||||||
- body: `{ "expectedRevision": 15 }`
|
- body: `{ "expectedRevision": 15 }`
|
||||||
- rejects floors with assigned rooms or distribution boards
|
- rejects floors with assigned rooms or distribution boards
|
||||||
|
|
@ -433,15 +399,14 @@ read nor write project data; applying a result uses `circuit.update` through
|
||||||
|
|
||||||
- `POST /cable-sizing/calculate`
|
- `POST /cable-sizing/calculate`
|
||||||
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
|
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
|
||||||
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` |
|
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D1` |
|
||||||
`E`), conductorMaterial, insulation (`pvc` | `xlpe`),
|
`D2` | `E`), conductorMaterial, insulation (`pvc` | `xlpe`),
|
||||||
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
|
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
|
||||||
harmonicNeutralLoad, optional `existingProtection`
|
harmonicNeutralLoad, optional `existingProtection`
|
||||||
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
|
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
|
||||||
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
|
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
|
||||||
is `false` with `unsupportedReason` when temperature or grouping lie
|
is `false` with `unsupportedReason` when temperature or grouping lie
|
||||||
outside the tables or the neutral current needed above 33 % third
|
outside the tables
|
||||||
harmonic is unavailable
|
|
||||||
- unknown fields are rejected (`400`)
|
- unknown fields are rejected (`400`)
|
||||||
- `GET /cable-sizing/laying-methods`
|
- `GET /cable-sizing/laying-methods`
|
||||||
- `GET /cable-sizing/insulation-materials`
|
- `GET /cable-sizing/insulation-materials`
|
||||||
|
|
@ -482,27 +447,3 @@ Full request example and calculation rules: `docs/cable-sizing-module.md`.
|
||||||
Parser errors expose stable codes such as `header-not-found`,
|
Parser errors expose stable codes such as `header-not-found`,
|
||||||
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
|
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
|
||||||
import uploads and parses the file again and verifies its hash.
|
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" } }`
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
# Circuit List Editor Interactions
|
# 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
|
## Editing Model
|
||||||
|
|
||||||
Inline cells are static text by default. A cell enters edit mode by:
|
Inline cells are static text by default. A cell enters edit mode by:
|
||||||
|
|
@ -47,7 +44,6 @@ Selection, keyboard movement, and editability checks run against this normalized
|
||||||
- arrow keys: move selected cell in grid when not editing
|
- 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
|
- 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
|
- `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
|
- `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.
|
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.
|
||||||
|
|
@ -56,23 +52,6 @@ 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.
|
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 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.
|
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.
|
||||||
|
|
@ -148,30 +127,11 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||||
|
|
||||||
## Filtering and Sorting
|
## 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.
|
- Per-column filtering works on normalized displayed values.
|
||||||
- Sorting is view-level first and treats circuits as blocks.
|
- Sorting is view-level first and treats circuits as blocks.
|
||||||
- Multi-device circuits are not split during sort.
|
- Multi-device circuits are not split during sort.
|
||||||
- Sorting alone does not persist order.
|
- 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
|
||||||
|
|
||||||
`Apply sorted order` persists current sorted block order to backend by section.
|
`Apply sorted order` persists current sorted block order to backend by section.
|
||||||
|
|
@ -201,11 +161,6 @@ across multiple sections is atomic and occupies one history step. Explicit
|
||||||
renumbering uses a collision-safe persistent project command and one
|
renumbering uses a collision-safe persistent project command and one
|
||||||
project-history step.
|
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:
|
Covered operations include:
|
||||||
|
|
||||||
- insert/delete circuit
|
- insert/delete circuit
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
- Named and automatic logical project snapshots can be listed and restored
|
- Named and automatic logical project snapshots can be listed and restored
|
||||||
through the project page with persistent Undo/Redo. Automatic snapshots are
|
through the project page with persistent Undo/Redo. Automatic snapshots are
|
||||||
created every 25 revisions and only their newest 12 entries are retained.
|
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.
|
- The Revit/CSV foundation and confirmed initial import are implemented.
|
||||||
Existing-row assignment, move and detach plus creation of an external-only
|
Existing-row assignment, move and detach plus creation of an external-only
|
||||||
row in an existing or newly created circuit have atomic persistent commands.
|
row in an existing or newly created circuit have atomic persistent commands.
|
||||||
|
|
@ -20,11 +18,11 @@
|
||||||
- The global device library supports basic CRUD and copy operations, but has no
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
versioning, permissions or controlled synchronization model.
|
versioning, permissions or controlled synchronization model.
|
||||||
- There is no automatic sizing of all circuits. The optional cable sizing
|
- There is no automatic sizing of all circuits. The optional cable sizing
|
||||||
module suggests a cross-section for one circuit on demand using
|
module suggests a cross-section for one circuit on demand (IEC 60364-5-52
|
||||||
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct
|
Annex B tables; no short-circuit, disconnection-time or selectivity checks,
|
||||||
burial, grouped method E and neutral-current sizing above 33 % third
|
no methods F/G, soil resistivity fixed at 2.5 K·m/W) and does not validate
|
||||||
harmonic, and it does not check short-circuit withstand, disconnection time
|
manually entered cable values. National tables (DIN VDE 0298-4) must be
|
||||||
or selectivity. See `docs/cable-sizing-module.md`.
|
checked by the planner; see `docs/cable-sizing-module.md`.
|
||||||
- Sorting is view-only until users explicitly apply sorted order.
|
- Sorting is view-only until users explicitly apply sorted order.
|
||||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
- Complete-circuit drag supports one circuit at a time between groups of the
|
||||||
same category. Cross-category moves and multi-circuit moves across group
|
same category. Cross-category moves and multi-circuit moves across group
|
||||||
|
|
@ -33,12 +31,3 @@
|
||||||
- Protection and cable values are planner-owned manual selections. The later
|
- Protection and cable values are planner-owned manual selections. The later
|
||||||
sizing/warning engine does not yet validate them against load, length or
|
sizing/warning engine does not yet validate them against load, length or
|
||||||
voltage-drop rules.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,9 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
|
||||||
data/leistungsbilanz.db (SQLite)
|
data/leistungsbilanz.db (SQLite)
|
||||||
```
|
```
|
||||||
|
|
||||||
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
|
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
|
||||||
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
|
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
|
||||||
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
|
liegt über einen Host-Mount außerhalb des Containers.
|
||||||
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
|
## Wichtige Einstiegspunkte
|
||||||
|
|
||||||
|
|
@ -241,9 +232,8 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
||||||
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
|
||||||
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
|
||||||
`project.update-settings` versioniert Projektname, interne und externe
|
`project.update-settings` versioniert Projektname, interne und externe
|
||||||
Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
|
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches
|
||||||
Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
|
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
|
||||||
`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
|
|
||||||
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
|
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
|
||||||
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
|
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
|
||||||
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
||||||
|
|
@ -255,55 +245,22 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
||||||
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
||||||
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||||
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
|
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
|
||||||
Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre
|
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||||
versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter
|
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||||
Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite
|
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||||
„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
|
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||||
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
|
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||||
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
|
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||||
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
|
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
|
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||||
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
|
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||||
`IconButton`/`IconLink`.
|
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||||
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
|
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
|
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
|
||||||
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
|
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
|
||||||
Fachliche Verknüpfungen
|
|
||||||
innerhalb des unterstützten Laufzeitmodells bleiben
|
innerhalb des unterstützten Laufzeitmodells bleiben
|
||||||
erhalten. Die
|
erhalten. Die
|
||||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||||
|
|
@ -459,16 +416,7 @@ wieder her.
|
||||||
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||||
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||||
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||||
Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||||
(`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
|
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
|
||||||
enthält bereits den vollständigen externen Modellzustand
|
enthält bereits den vollständigen externen Modellzustand
|
||||||
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
|
||||||
|
|
@ -507,10 +455,8 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||||
## Leitungsberechnung
|
## Leitungsberechnung
|
||||||
|
|
||||||
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
|
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
|
||||||
Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur
|
Querschnittsempfehlung für einen Stromkreis (IEC 60364-5-52 Anhang B:
|
||||||
und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich
|
Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan). Die
|
||||||
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
|
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
|
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
|
||||||
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
|
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
|
||||||
|
|
@ -552,7 +498,7 @@ Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
|
||||||
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
|
||||||
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
|
||||||
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
|
||||||
Transfer-Integration verwenden aktuell Snapshot-Schema 6. Persistente
|
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
|
||||||
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
|
||||||
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
Gruppen einschließlich befüllter Unterbäume sowie vollständige
|
||||||
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
|
||||||
|
|
@ -571,10 +517,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
|
||||||
|
|
||||||
## Revit-CSV-Grundlage
|
## 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
|
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
|
||||||
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
|
||||||
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
|
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
|
||||||
|
|
@ -584,7 +526,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
|
||||||
|
|
||||||
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
|
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
|
||||||
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
|
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
|
||||||
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 6,
|
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
|
||||||
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
|
||||||
werden Konfigurations-UUID und Projektlink remapped.
|
werden Konfigurations-UUID und Projektlink remapped.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,9 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
||||||
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
||||||
Entwicklungsrechner.
|
Entwicklungsrechner.
|
||||||
|
|
||||||
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
|
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
||||||
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
|
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
||||||
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
|
erreichbar gemacht werden.
|
||||||
deshalb nicht öffentlich erreichbar gemacht werden.
|
|
||||||
|
|
||||||
## Topologie
|
## Topologie
|
||||||
|
|
||||||
|
|
@ -35,11 +34,6 @@ 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
|
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.
|
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
|
### Speichergrenzen
|
||||||
|
|
||||||
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
|
||||||
|
|
@ -146,17 +140,14 @@ Vor einer produktiven Installation werden mindestens benötigt:
|
||||||
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
|
||||||
Mehrbenutzerbetrieb erforderlich ist
|
Mehrbenutzerbetrieb erforderlich ist
|
||||||
|
|
||||||
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
|
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
|
||||||
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
|
lokalen Entwicklungsstart in der README.
|
||||||
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
|
|
||||||
|
|
||||||
## Backup und Wiederherstellung
|
## Backup und Wiederherstellung
|
||||||
|
|
||||||
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
|
`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
|
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
|
||||||
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
|
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
|
||||||
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
|
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
|
||||||
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
|
||||||
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
|
||||||
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
|
angelegt. Das Frontend ist unter <http://localhost:3001> 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:
|
Für direkte Node.js-Entwicklung:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
@ -91,8 +86,7 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
|
||||||
```powershell
|
```powershell
|
||||||
docker compose up --build --detach
|
docker compose up --build --detach
|
||||||
docker compose ps
|
docker compose ps
|
||||||
Invoke-WebRequest http://localhost:3001/health
|
Invoke-WebRequest http://localhost:3000/health
|
||||||
Invoke-WebRequest http://localhost:3001/web-health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
# 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 – <Projekt>`
|
|
||||||
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, P<sub>inst</sub> (Anzahl × Leistung je
|
|
||||||
Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF,
|
|
||||||
P<sub>gl</sub>, I<sub>B</sub> und „Σ inkl. unterlagert“.
|
|
||||||
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
|
|
||||||
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
|
|
||||||
P<sub>gl</sub>; 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.
|
|
||||||
- **I<sub>B</sub>** ist ein symmetrischer Drehstromwert
|
|
||||||
S / (√3 · U<sub>3~</sub>) 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.
|
|
||||||
|
|
@ -113,10 +113,6 @@ Implemented automatic-snapshot policy:
|
||||||
|
|
||||||
## Revit / CSV / IFCGUID Round-Trip Direction
|
## 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.
|
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
|
||||||
|
|
||||||
Conceptual entities:
|
Conceptual entities:
|
||||||
|
|
@ -279,10 +275,6 @@ Completed foundation:
|
||||||
- `project-floor.insert` and `project-room.insert` persist complete location
|
- `project-floor.insert` and `project-room.insert` persist complete location
|
||||||
records with stable ids; their inverses reject changed or referenced records
|
records with stable ids; their inverses reject changed or referenced records
|
||||||
so Undo never clears room assignments or device-row links implicitly
|
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
|
- the floor and room POST routes require the current expected revision and the
|
||||||
project page tracks the returned history state
|
project page tracks the returned history state
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
# Kontext für die Revit-Anforderungsplanung
|
# 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
|
## Zweck dieser Datei
|
||||||
|
|
||||||
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
||||||
|
|
|
||||||
|
|
@ -532,10 +532,6 @@ Acceptance criteria:
|
||||||
|
|
||||||
## Phase 14: External Model Round-Trip
|
## 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
|
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
|
||||||
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
|
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
|
||||||
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
|
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
|
||||||
|
|
@ -544,7 +540,7 @@ und die verbindlichen
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
|
|
||||||
Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import).
|
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,8 @@ requirements and intended sequencing, not proof of implementation.
|
||||||
- [x] Phase F: documentation and full GUI verification.
|
- [x] Phase F: documentation and full GUI verification.
|
||||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||||
the planner supplies the calculation requirements. The optional per-circuit
|
the planner supplies the calculation requirements. The optional per-circuit
|
||||||
cable sizing module (`docs/cable-sizing-module.md`) exists; its supported
|
cable sizing module (`docs/cable-sizing-module.md`) exists; its tables must
|
||||||
tables were confirmed against DIN VDE 0298-4:2023-06 with
|
be confirmed by the planner against DIN VDE 0298-4.
|
||||||
Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked.
|
|
||||||
- Detailed specification:
|
- Detailed specification:
|
||||||
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
`docs/spec/09-distribution-board-components-and-protection-groups.md`
|
||||||
|
|
||||||
|
|
@ -144,5 +143,4 @@ The pre-release cleanup established the following compatibility boundary:
|
||||||
|
|
||||||
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
|
||||||
foundation as a separate jointly planned phase and must not be inferred from
|
foundation as a separate jointly planned phase and must not be inferred from
|
||||||
these tasks. It is currently deferred and will later use the CSV
|
these tasks.
|
||||||
export/import of the Linear plugin in Revit, not Dynamo.
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,6 @@
|
||||||
|
|
||||||
## 1. Status und Verbindlichkeit
|
## 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**.
|
Dieses Dokument ist die verbindliche fachliche und technische Arbeitsgrundlage für **Phase 14: External Model Round-Trip**.
|
||||||
|
|
||||||
Es unterscheidet ausdrücklich zwischen:
|
Es unterscheidet ausdrücklich zwischen:
|
||||||
|
|
@ -1351,7 +1338,7 @@ Diese Punkte blockieren die Erstellung dieser Spezifikation nicht.
|
||||||
- automatische raumbezogene Stromkreisvorschläge;
|
- automatische raumbezogene Stromkreisvorschläge;
|
||||||
- projektübergreifend wiederverwendbare Mappingprofile;
|
- projektübergreifend wiederverwendbare Mappingprofile;
|
||||||
- Teilmodelle und mehrere externe Quellen;
|
- Teilmodelle und mehrere externe Quellen;
|
||||||
- Dynamo/API/JSON-Transportadapter (nicht vorgesehen, siehe Abschnitt 1);
|
- Dynamo/API/JSON-Transportadapter;
|
||||||
- Rollen und Berechtigungen;
|
- Rollen und Berechtigungen;
|
||||||
- Hintergrundjobs;
|
- Hintergrundjobs;
|
||||||
- PostgreSQL.
|
- PostgreSQL.
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
## Status
|
## 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
|
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
|
||||||
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
|
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
|
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem
|
||||||
|
|
|
||||||
385
dynamo/01_check_model_identity.py
Normal file
385
dynamo/01_check_model_identity.py
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
"""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(),
|
||||||
|
}
|
||||||
517
dynamo/02_export_electrical_fixture_parameter_inventory.py
Normal file
517
dynamo/02_export_electrical_fixture_parameter_inventory.py
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
"""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(),
|
||||||
|
}
|
||||||
69
dynamo/README.md
Normal file
69
dynamo/README.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -5,10 +5,6 @@ const dbPath = path.resolve("data", "leistungsbilanz.db");
|
||||||
const db = new Database(dbPath, { readonly: true });
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
const requiredTables = [
|
const requiredTables = [
|
||||||
"buildings",
|
|
||||||
"estimate_balances",
|
|
||||||
"estimate_groups",
|
|
||||||
"estimate_rows",
|
|
||||||
"circuit_sections",
|
"circuit_sections",
|
||||||
"circuits",
|
"circuits",
|
||||||
"circuit_device_rows",
|
"circuit_device_rows",
|
||||||
|
|
@ -16,8 +12,6 @@ const requiredTables = [
|
||||||
"circuit_protection_devices",
|
"circuit_protection_devices",
|
||||||
"distribution_board_components",
|
"distribution_board_components",
|
||||||
"distribution_board_component_protection_devices",
|
"distribution_board_component_protection_devices",
|
||||||
"document_branding_assets",
|
|
||||||
"document_branding_settings",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
|
||||||
|
|
@ -93,10 +87,6 @@ const requiredProjectColumns = [
|
||||||
"description",
|
"description",
|
||||||
"is_public_building",
|
"is_public_building",
|
||||||
"enabled_distribution_board_supply_types",
|
"enabled_distribution_board_supply_types",
|
||||||
"site_address",
|
|
||||||
"work_stage",
|
|
||||||
"project_lead",
|
|
||||||
"project_editor",
|
|
||||||
];
|
];
|
||||||
const projectColumns = new Set(
|
const projectColumns = new Set(
|
||||||
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
||||||
|
|
@ -104,21 +94,10 @@ const projectColumns = new Set(
|
||||||
const missingProjectColumns = requiredProjectColumns.filter(
|
const missingProjectColumns = requiredProjectColumns.filter(
|
||||||
(name) => !projectColumns.has(name)
|
(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 = [
|
const requiredDistributionBoardColumns = [
|
||||||
"floor_id",
|
"floor_id",
|
||||||
"supply_type",
|
"supply_type",
|
||||||
"simultaneity_factor",
|
"simultaneity_factor",
|
||||||
"board_number",
|
|
||||||
"location",
|
|
||||||
"fed_from",
|
|
||||||
"remark",
|
|
||||||
];
|
];
|
||||||
const distributionBoardColumns = new Set(
|
const distributionBoardColumns = new Set(
|
||||||
db
|
db
|
||||||
|
|
@ -166,7 +145,6 @@ console.log(
|
||||||
requiredCircuitSectionColumns.join(", ")
|
requiredCircuitSectionColumns.join(", ")
|
||||||
);
|
);
|
||||||
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||||
console.log("Required floor columns:", requiredFloorColumns.join(", "));
|
|
||||||
console.log(
|
console.log(
|
||||||
"Required distribution-board columns:",
|
"Required distribution-board columns:",
|
||||||
requiredDistributionBoardColumns.join(", ")
|
requiredDistributionBoardColumns.join(", ")
|
||||||
|
|
@ -183,11 +161,6 @@ if (missing.length > 0) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingFloorColumns.length > 0) {
|
|
||||||
console.error("Missing floor columns:", missingFloorColumns.join(", "));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingProjectDeviceColumns.length > 0) {
|
if (missingProjectDeviceColumns.length > 0) {
|
||||||
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
|
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
1166
src/app/globals.css
1166
src/app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
||||||
|
|
@ -11,19 +10,9 @@ export default function CircuitTreeEditPage() {
|
||||||
return (
|
return (
|
||||||
<main className="container-fluid px-3 px-xxl-4 py-4">
|
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div>
|
||||||
<Link
|
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||||
aria-label="Zurück zur Verteilerübersicht"
|
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||||
className="btn btn-outline-secondary btn-sm"
|
|
||||||
href={`/projects/${params.projectId}`}
|
|
||||||
title="Zurück zur Verteilerübersicht"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" size={18} />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
|
||||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
|
||||||
|
|
|
||||||
|
|
@ -1,745 +0,0 @@
|
||||||
"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<ProjectDto | null>(null);
|
|
||||||
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
|
||||||
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
|
||||||
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
|
||||||
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
|
||||||
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isMissing, setIsMissing] = useState(false);
|
|
||||||
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
|
||||||
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
|
||||||
() => new Set()
|
|
||||||
);
|
|
||||||
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(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<EstimateBalanceDto | null>(null);
|
|
||||||
const revisionRef = useRef<number | null>(null);
|
|
||||||
const savedFingerprintRef = useRef<string | null>(null);
|
|
||||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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<boolean> => {
|
|
||||||
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<HTMLAnchorElement>) {
|
|
||||||
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 (
|
|
||||||
<main className="container-fluid py-4 estimate-print-root">
|
|
||||||
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
|
|
||||||
<div className="page-header estimate-page-header">
|
|
||||||
<div className="d-flex align-items-start gap-3">
|
|
||||||
<Link
|
|
||||||
aria-label="Zurück zur Übersicht der Stände"
|
|
||||||
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
|
||||||
href={listHref}
|
|
||||||
onClick={(event) => void handleBackClick(event)}
|
|
||||||
title="Zurück zur Übersicht der Stände"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" size={18} />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
|
||||||
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
|
||||||
<p className="text-secondary mb-0 estimate-no-print">
|
|
||||||
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="estimate-toolbar estimate-no-print">
|
|
||||||
<SaveStatusBadge
|
|
||||||
error={saveError}
|
|
||||||
invalidCount={invalidCount}
|
|
||||||
onRetry={() => void saveNow()}
|
|
||||||
status={saveStatus}
|
|
||||||
/>
|
|
||||||
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
|
||||||
<button
|
|
||||||
aria-label="Rückgängig"
|
|
||||||
className="btn btn-outline-secondary"
|
|
||||||
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
|
||||||
onClick={() => void handleHistory("undo")}
|
|
||||||
title="Rückgängig (letzte Änderung im Projekt)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Undo2 aria-hidden="true" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="Wiederholen"
|
|
||||||
className="btn btn-outline-secondary"
|
|
||||||
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
|
||||||
onClick={() => void handleHistory("redo")}
|
|
||||||
title="Wiederholen (im Projekt)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Redo2 aria-hidden="true" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
aria-label="Drucken"
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={!draft}
|
|
||||||
onClick={() => window.print()}
|
|
||||||
title="Drucken"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Printer aria-hidden="true" className="me-1" size={16} />
|
|
||||||
Drucken
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<div className="alert alert-info estimate-no-print" role="status">
|
|
||||||
{notice}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<div className="alert alert-warning estimate-no-print" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="text-secondary" role="status">
|
|
||||||
Grobe Leistungsbilanz wird geladen …
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{isMissing ? (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
|
||||||
rückgängig gemacht wurde.{" "}
|
|
||||||
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{draft && project ? (
|
|
||||||
<>
|
|
||||||
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="estimate-header-fields">
|
|
||||||
<div className="estimate-header-field">
|
|
||||||
<span className="estimate-header-label">Projekt</span>
|
|
||||||
<span className="estimate-header-value">{project.name}</span>
|
|
||||||
</div>
|
|
||||||
<label className="estimate-header-field is-wide">
|
|
||||||
<span className="estimate-header-label">Stand</span>
|
|
||||||
<DraftInput
|
|
||||||
ariaLabel="Bezeichnung des Stands"
|
|
||||||
className="estimate-cell-input estimate-header-input"
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
format={(value: string) => value}
|
|
||||||
maxLength={estimateTextLimits.balanceName}
|
|
||||||
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
|
||||||
onValidityChange={(isValid) =>
|
|
||||||
handleValidityChange("balance:name", isValid)
|
|
||||||
}
|
|
||||||
parse={(text) =>
|
|
||||||
text.trim()
|
|
||||||
? { ok: true, value: text }
|
|
||||||
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
|
||||||
}
|
|
||||||
value={draft.name}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="estimate-header-field">
|
|
||||||
<span className="estimate-header-label">LPH</span>
|
|
||||||
<input
|
|
||||||
aria-label="Leistungsphase"
|
|
||||||
className="estimate-cell-input estimate-header-input"
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
maxLength={estimateTextLimits.workStage}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleChange((current) => ({
|
|
||||||
...current,
|
|
||||||
workStage: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="z. B. LPH 3"
|
|
||||||
type="text"
|
|
||||||
value={draft.workStage ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
|
||||||
</label>
|
|
||||||
<label className="estimate-header-field">
|
|
||||||
<span className="estimate-header-label">Datum</span>
|
|
||||||
<input
|
|
||||||
aria-label="Datum"
|
|
||||||
className="estimate-cell-input estimate-header-input"
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleChange((current) => ({
|
|
||||||
...current,
|
|
||||||
dateIso: event.target.value || null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
type="date"
|
|
||||||
value={draft.dateIso ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">
|
|
||||||
{formatIsoDateGerman(draft.dateIso)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<EstimateBalanceSheet
|
|
||||||
balance={draft}
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
onAddGroup={() => setGroupDialog({ group: null })}
|
|
||||||
onChange={handleChange}
|
|
||||||
onEditGroup={(group) => setGroupDialog({ group })}
|
|
||||||
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
|
||||||
onValidityChange={handleValidityChange}
|
|
||||||
threePhaseVoltageV={project.threePhaseVoltageV}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className={
|
|
||||||
isComparisonOpen
|
|
||||||
? "card shadow-sm estimate-comparison-card"
|
|
||||||
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
|
||||||
<button
|
|
||||||
aria-controls="estimate-comparison-body"
|
|
||||||
aria-expanded={isComparisonOpen}
|
|
||||||
className="estimate-collapse-toggle"
|
|
||||||
onClick={() => setIsComparisonOpen((open) => !open)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isComparisonOpen ? (
|
|
||||||
<ChevronDown aria-hidden="true" size={16} />
|
|
||||||
) : (
|
|
||||||
<ChevronRight aria-hidden="true" size={16} />
|
|
||||||
)}
|
|
||||||
Vergleich mit detaillierter Bilanz
|
|
||||||
</button>
|
|
||||||
{isComparisonOpen ? (
|
|
||||||
<button
|
|
||||||
aria-label="Vergleich neu laden"
|
|
||||||
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
|
||||||
onClick={() => void reloadComparison()}
|
|
||||||
title="Vergleich neu laden"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw aria-hidden="true" size={14} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{isComparisonOpen ? (
|
|
||||||
<div className="card-body" id="estimate-comparison-body">
|
|
||||||
{comparisonError ? (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
{comparisonError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
|
||||||
<p className="text-secondary small mt-2 mb-0">
|
|
||||||
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
|
||||||
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
|
||||||
Verteiler-GZF.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{groupDialog ? (
|
|
||||||
<EstimateGroupModal
|
|
||||||
group={groupDialog.group}
|
|
||||||
onClose={() => setGroupDialog(null)}
|
|
||||||
onSave={handleSaveGroup}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{gzfTarget ? (
|
|
||||||
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="estimate-save-status" role="status" aria-live="polite">
|
|
||||||
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{invalidCount ? (
|
|
||||||
<span className="estimate-save-hint">
|
|
||||||
{invalidCount === 1
|
|
||||||
? "1 ungültige Eingabe wird nicht gespeichert"
|
|
||||||
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{status === "error" ? (
|
|
||||||
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
|
||||||
Erneut versuchen
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
"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<ProjectDto | null>(null);
|
|
||||||
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
|
|
||||||
const [createDefaults, setCreateDefaults] =
|
|
||||||
useState<EstimateBalanceCreateDefaults | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(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<TResult extends ProjectCommandResultDto>(
|
|
||||||
execute: (expectedRevision: number) => Promise<TResult>,
|
|
||||||
fallbackMessage: string
|
|
||||||
): Promise<TResult | null> {
|
|
||||||
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 (
|
|
||||||
<main className="container py-4">
|
|
||||||
<div className="page-header">
|
|
||||||
<div className="d-flex align-items-start gap-3">
|
|
||||||
<Link
|
|
||||||
aria-label="Zurück zur Projektübersicht"
|
|
||||||
className="btn btn-outline-secondary btn-sm structure-back-link"
|
|
||||||
href={`/projects/${projectId}`}
|
|
||||||
title="Zurück zur Projektübersicht"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" size={18} />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
|
||||||
<h1>Grobe Leistungsbilanz</h1>
|
|
||||||
<p className="text-secondary mb-0">
|
|
||||||
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
|
|
||||||
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => setCreateDefaults({})}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="me-1" size={16} />
|
|
||||||
Neuer Stand
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="text-secondary" role="status">
|
|
||||||
Grobe Leistungsbilanzen werden geladen …
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
|
|
||||||
<div className="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h2 className="structure-column-title" id="estimate-list-heading">
|
|
||||||
Stände
|
|
||||||
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{balances.length ? (
|
|
||||||
<div className="table-responsive">
|
|
||||||
<table className="table table-hover align-middle mb-0 estimate-list-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Stand</th>
|
|
||||||
<th>LPH</th>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th className="text-end">AV kW</th>
|
|
||||||
<th className="text-end">AV kVA</th>
|
|
||||||
<th className="text-end">SV kW</th>
|
|
||||||
<th className="text-end">SV kVA</th>
|
|
||||||
<th className="text-end">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{balances.map((balance) => {
|
|
||||||
const result = calculateEstimateBalance(balance, voltage);
|
|
||||||
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
|
|
||||||
const rowCount = countRows(balance);
|
|
||||||
return (
|
|
||||||
<tr key={balance.id}>
|
|
||||||
<td>
|
|
||||||
<Link className="fw-semibold" href={href}>
|
|
||||||
{balance.name}
|
|
||||||
</Link>
|
|
||||||
<div className="text-secondary small">
|
|
||||||
{balance.groups.length} Kostengruppen ·{" "}
|
|
||||||
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{balance.workStage ?? "–"}</td>
|
|
||||||
<td>{formatIsoDateGerman(balance.dateIso)}</td>
|
|
||||||
<td className="text-end estimate-number">
|
|
||||||
{formatKw(result.withReserve.av.kw)}
|
|
||||||
</td>
|
|
||||||
<td className="text-end estimate-number">
|
|
||||||
{formatKw(result.withReserve.av.kva)}
|
|
||||||
</td>
|
|
||||||
<td className="text-end estimate-number">
|
|
||||||
{formatKw(result.withReserve.sv.kw)}
|
|
||||||
</td>
|
|
||||||
<td className="text-end estimate-number">
|
|
||||||
{formatKw(result.withReserve.sv.kva)}
|
|
||||||
</td>
|
|
||||||
<td className="text-end">
|
|
||||||
<div className="d-inline-flex gap-1">
|
|
||||||
<Link
|
|
||||||
aria-label={`Stand „${balance.name}“ öffnen`}
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
href={href}
|
|
||||||
title="Öffnen"
|
|
||||||
>
|
|
||||||
<FolderOpen aria-hidden="true" className="me-1" size={14} />
|
|
||||||
Öffnen
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
|
|
||||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() =>
|
|
||||||
setCreateDefaults({
|
|
||||||
name: createCopyName(balance.name),
|
|
||||||
workStage: balance.workStage,
|
|
||||||
template: "copy",
|
|
||||||
copyFromBalanceId: balance.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
title="Als neuen Stand kopieren"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Copy aria-hidden="true" size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label={`Stand „${balance.name}“ löschen`}
|
|
||||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => void handleDelete(balance)}
|
|
||||||
title="Stand löschen"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p className="text-secondary small px-3 py-2 mb-0">
|
|
||||||
Leistungen jeweils inklusive Reserve.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : !isLoading ? (
|
|
||||||
<div className="card-body">
|
|
||||||
<p className="structure-empty mb-0">
|
|
||||||
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
|
|
||||||
Basis der Vorlage KG 400 anlegen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{createDefaults ? (
|
|
||||||
<EstimateBalanceCreateModal
|
|
||||||
balances={balances}
|
|
||||||
defaults={createDefaults}
|
|
||||||
isSaving={isSaving}
|
|
||||||
onClose={() => setCreateDefaults(null)}
|
|
||||||
onCreate={(input) => void handleCreate(input)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,360 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
|
|
||||||
import "../../../../frontend/print/print-document.css";
|
|
||||||
import {
|
|
||||||
deleteDocumentLogo,
|
|
||||||
documentLogoUrl,
|
|
||||||
getCircuitTree,
|
|
||||||
getDocumentBranding,
|
|
||||||
getProject,
|
|
||||||
listCircuitLists,
|
|
||||||
listDistributionBoards,
|
|
||||||
listFloors,
|
|
||||||
listProjectSnapshots,
|
|
||||||
updateOfficeProfile,
|
|
||||||
uploadDocumentLogo,
|
|
||||||
} from "../../../../frontend/utils/api";
|
|
||||||
import type {
|
|
||||||
DocumentLogoMetadataDto,
|
|
||||||
OfficeProfileDto,
|
|
||||||
ProjectDto,
|
|
||||||
ProjectSnapshotMetadataDto,
|
|
||||||
} from "../../../../frontend/types";
|
|
||||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
|
||||||
import {
|
|
||||||
officeProfileFieldLabels,
|
|
||||||
officeProfileFieldMaxLengths,
|
|
||||||
} from "../../../../shared/constants/project-cover";
|
|
||||||
import { PrintDocument } from "../../../../frontend/print/print-document";
|
|
||||||
import {
|
|
||||||
assertPrintRevisionConsistency,
|
|
||||||
buildPreliminaryBoardHierarchy,
|
|
||||||
summarizePrintBoard,
|
|
||||||
type PrintBoardSummary,
|
|
||||||
} from "../../../../frontend/print/print-document-model";
|
|
||||||
import {
|
|
||||||
documentLogoMimeTypes,
|
|
||||||
maxDocumentLogoBytes,
|
|
||||||
} from "../../../../shared/constants/document-branding";
|
|
||||||
|
|
||||||
type PlanFormat = "a4" | "a3";
|
|
||||||
|
|
||||||
interface PrintData {
|
|
||||||
project: ProjectDto;
|
|
||||||
summaries: PrintBoardSummary[];
|
|
||||||
namedSnapshots: ProjectSnapshotMetadataDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardSortCollator = new Intl.Collator("de", { numeric: true });
|
|
||||||
|
|
||||||
function cssString(value: string) {
|
|
||||||
return JSON.stringify(value.replace(/[<>\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<PrintData | null>(null);
|
|
||||||
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
|
|
||||||
const [office, setOffice] = useState<OfficeProfileDto>({
|
|
||||||
officeName: null,
|
|
||||||
officeAddress: null,
|
|
||||||
});
|
|
||||||
const [officeDraft, setOfficeDraft] = useState<{
|
|
||||||
officeName: string;
|
|
||||||
officeAddress: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [format, setFormat] = useState<PlanFormat>("a4");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [message, setMessage] = useState<string | null>(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<HTMLInputElement>) {
|
|
||||||
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<HTMLFormElement>) {
|
|
||||||
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.
|
|
||||||
<div
|
|
||||||
className={format === "a3" ? "print-view pd-a3" : "print-view"}
|
|
||||||
data-bs-theme="light"
|
|
||||||
>
|
|
||||||
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
|
|
||||||
<div className="pd-toolbar">
|
|
||||||
<span className="pd-toolbar-title">
|
|
||||||
Seitenansicht{data ? ` · ${data.project.name}` : ""}
|
|
||||||
</span>
|
|
||||||
{message ? <span className="pd-toolbar-message">{message}</span> : null}
|
|
||||||
{error ? <span className="pd-toolbar-error">{error}</span> : null}
|
|
||||||
<label className="d-flex align-items-center gap-2">
|
|
||||||
Planformat
|
|
||||||
<select
|
|
||||||
className="form-select form-select-sm w-auto"
|
|
||||||
onChange={(event) => setFormat(event.target.value as PlanFormat)}
|
|
||||||
value={format}
|
|
||||||
>
|
|
||||||
<option value="a4">A4 quer</option>
|
|
||||||
<option value="a3">A3 quer</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() =>
|
|
||||||
setOfficeDraft({
|
|
||||||
officeName: office.officeName ?? "",
|
|
||||||
officeAddress: office.officeAddress ?? "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Büroangaben
|
|
||||||
</button>
|
|
||||||
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
|
|
||||||
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
|
|
||||||
<input
|
|
||||||
accept={documentLogoMimeTypes.join(",")}
|
|
||||||
disabled={isBusy}
|
|
||||||
hidden
|
|
||||||
onChange={(event) => void handleLogoUpload(event)}
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{logo ? (
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={isBusy}
|
|
||||||
onClick={() => void handleLogoDelete()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Logo entfernen
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-primary"
|
|
||||||
disabled={!data}
|
|
||||||
onClick={() => window.print()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Als PDF speichern / Drucken
|
|
||||||
</button>
|
|
||||||
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
|
|
||||||
Zum Projekt
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{officeDraft ? (
|
|
||||||
<FormModal
|
|
||||||
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
|
|
||||||
isSaving={isBusy}
|
|
||||||
onClose={() => setOfficeDraft(null)}
|
|
||||||
onSubmit={handleOfficeSave}
|
|
||||||
submitLabel="Büroangaben speichern"
|
|
||||||
title="Büroangaben"
|
|
||||||
>
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label" htmlFor="office-name">
|
|
||||||
{officeProfileFieldLabels.officeName}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="form-control"
|
|
||||||
id="office-name"
|
|
||||||
maxLength={officeProfileFieldMaxLengths.officeName}
|
|
||||||
onChange={(event) =>
|
|
||||||
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
|
|
||||||
}
|
|
||||||
value={officeDraft.officeName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label" htmlFor="office-address">
|
|
||||||
{officeProfileFieldLabels.officeAddress}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
className="form-control"
|
|
||||||
id="office-address"
|
|
||||||
maxLength={officeProfileFieldMaxLengths.officeAddress}
|
|
||||||
onChange={(event) =>
|
|
||||||
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
|
|
||||||
}
|
|
||||||
placeholder={"Straße Nr.\nPLZ Ort"}
|
|
||||||
rows={3}
|
|
||||||
value={officeDraft.officeAddress}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
) : null}
|
|
||||||
{data ? (
|
|
||||||
<PrintDocument
|
|
||||||
entries={entries}
|
|
||||||
logoSrc={logoSrc}
|
|
||||||
namedSnapshots={data.namedSnapshots}
|
|
||||||
office={office}
|
|
||||||
printedAt={printedAt}
|
|
||||||
project={data.project}
|
|
||||||
/>
|
|
||||||
) : error ? null : (
|
|
||||||
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,792 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import {
|
|
||||||
type FormEvent,
|
|
||||||
type ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
|
|
||||||
import {
|
|
||||||
createBuilding,
|
|
||||||
createFloor,
|
|
||||||
createRoom,
|
|
||||||
deleteBuilding,
|
|
||||||
deleteFloor,
|
|
||||||
deleteRoom,
|
|
||||||
getProject,
|
|
||||||
listBuildings,
|
|
||||||
listFloors,
|
|
||||||
listRooms,
|
|
||||||
updateBuilding,
|
|
||||||
updateFloor,
|
|
||||||
updateRoom,
|
|
||||||
} from "../../../../frontend/utils/api";
|
|
||||||
import type {
|
|
||||||
BuildingDto,
|
|
||||||
CreateRoomInput,
|
|
||||||
FloorDto,
|
|
||||||
ProjectCommandResultDto,
|
|
||||||
ProjectDto,
|
|
||||||
RoomDto,
|
|
||||||
} from "../../../../frontend/types";
|
|
||||||
import {
|
|
||||||
ALL_SELECTION,
|
|
||||||
UNASSIGNED_BUILDING_LABEL,
|
|
||||||
UNASSIGNED_FLOOR_LABEL,
|
|
||||||
UNASSIGNED_SELECTION,
|
|
||||||
countFloorsByBuilding,
|
|
||||||
countRoomsByFloor,
|
|
||||||
defaultBuildingIdForNewFloor,
|
|
||||||
defaultFloorIdForNewRoom,
|
|
||||||
describeRoomLocation,
|
|
||||||
filterFloorsByBuilding,
|
|
||||||
filterRooms,
|
|
||||||
groupFloorsByBuilding,
|
|
||||||
hasFloorsWithoutBuilding,
|
|
||||||
normalizeBuildingSelection,
|
|
||||||
normalizeFloorSelection,
|
|
||||||
showsRoomsWithoutFloorEntry,
|
|
||||||
type StructureSelection,
|
|
||||||
} from "../../../../frontend/utils/building-structure";
|
|
||||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
|
||||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
|
||||||
|
|
||||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
|
||||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
|
||||||
|
|
||||||
type StructureDialog =
|
|
||||||
| { kind: "building"; building: BuildingDto | null }
|
|
||||||
| { kind: "floor"; floor: FloorDto | null }
|
|
||||||
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
|
|
||||||
|
|
||||||
function formatCount(count: number, singular: string, plural: string) {
|
|
||||||
return `${count} ${count === 1 ? singular : plural}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
|
||||||
return (
|
|
||||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
|
||||||
left.roomName.localeCompare(right.roomName, "de")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StructureColumnProps {
|
|
||||||
addLabel: string;
|
|
||||||
children: ReactNode;
|
|
||||||
count: number;
|
|
||||||
headingId: string;
|
|
||||||
isAddDisabled: boolean;
|
|
||||||
onAdd: () => void;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StructureColumn({
|
|
||||||
addLabel,
|
|
||||||
children,
|
|
||||||
count,
|
|
||||||
headingId,
|
|
||||||
isAddDisabled,
|
|
||||||
onAdd,
|
|
||||||
title,
|
|
||||||
}: StructureColumnProps) {
|
|
||||||
return (
|
|
||||||
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
|
|
||||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
|
||||||
<h2 className="structure-column-title" id={headingId}>
|
|
||||||
{title}
|
|
||||||
<span className="badge text-bg-secondary ms-2">{count}</span>
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
aria-label={addLabel}
|
|
||||||
className="btn btn-sm btn-outline-primary structure-icon-button"
|
|
||||||
disabled={isAddDisabled}
|
|
||||||
onClick={onAdd}
|
|
||||||
title={addLabel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ul className="list-group list-group-flush structure-list">{children}</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StructureEntryProps {
|
|
||||||
actions?: ReactNode;
|
|
||||||
hint?: string;
|
|
||||||
isSelected?: boolean;
|
|
||||||
label: ReactNode;
|
|
||||||
meta?: string;
|
|
||||||
onSelect?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StructureEntry({
|
|
||||||
actions,
|
|
||||||
hint,
|
|
||||||
isSelected = false,
|
|
||||||
label,
|
|
||||||
meta,
|
|
||||||
onSelect,
|
|
||||||
}: StructureEntryProps) {
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<span className="structure-entry-text">
|
|
||||||
<span className="structure-entry-label">{label}</span>
|
|
||||||
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
|
|
||||||
</span>
|
|
||||||
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
className={
|
|
||||||
isSelected
|
|
||||||
? "list-group-item structure-entry is-selected"
|
|
||||||
: "list-group-item structure-entry"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{onSelect ? (
|
|
||||||
<button
|
|
||||||
aria-pressed={isSelected}
|
|
||||||
className="structure-entry-main"
|
|
||||||
onClick={onSelect}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="structure-entry-main">{content}</div>
|
|
||||||
)}
|
|
||||||
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntryActionsProps {
|
|
||||||
deleteLabel: string;
|
|
||||||
editLabel: string;
|
|
||||||
isDisabled: boolean;
|
|
||||||
onDelete: () => void;
|
|
||||||
onEdit: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EntryActions({
|
|
||||||
deleteLabel,
|
|
||||||
editLabel,
|
|
||||||
isDisabled,
|
|
||||||
onDelete,
|
|
||||||
onEdit,
|
|
||||||
}: EntryActionsProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
aria-label={editLabel}
|
|
||||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={onEdit}
|
|
||||||
title={editLabel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Pencil aria-hidden="true" size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label={deleteLabel}
|
|
||||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={onDelete}
|
|
||||||
title={deleteLabel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" size={14} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BuildingStructurePage() {
|
|
||||||
const params = useParams<{ projectId: string }>();
|
|
||||||
const projectId = params.projectId;
|
|
||||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
|
||||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
|
||||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
|
||||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
|
||||||
const [buildingSelection, setBuildingSelection] =
|
|
||||||
useState<StructureSelection>(ALL_SELECTION);
|
|
||||||
const [floorSelection, setFloorSelection] =
|
|
||||||
useState<StructureSelection>(ALL_SELECTION);
|
|
||||||
const [dialog, setDialog] = useState<StructureDialog | null>(null);
|
|
||||||
const [dialogName, setDialogName] = useState("");
|
|
||||||
const [dialogBuildingId, setDialogBuildingId] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
const reloadStructure = useCallback(async () => {
|
|
||||||
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
|
|
||||||
await Promise.all([
|
|
||||||
getProject(projectId),
|
|
||||||
listBuildings(projectId),
|
|
||||||
listFloors(projectId),
|
|
||||||
listRooms(projectId),
|
|
||||||
]);
|
|
||||||
setProject(currentProject);
|
|
||||||
setBuildings(loadedBuildings);
|
|
||||||
setFloors(loadedFloors);
|
|
||||||
setRooms(loadedRooms);
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
reloadStructure()
|
|
||||||
.then(() => setError(null))
|
|
||||||
.catch((err: unknown) =>
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Gebäudestruktur konnte nicht geladen werden."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, [projectId, reloadStructure]);
|
|
||||||
|
|
||||||
const effectiveBuildingSelection = normalizeBuildingSelection(
|
|
||||||
buildingSelection,
|
|
||||||
floors,
|
|
||||||
buildings
|
|
||||||
);
|
|
||||||
const effectiveFloorSelection = normalizeFloorSelection(
|
|
||||||
floorSelection,
|
|
||||||
rooms,
|
|
||||||
floors,
|
|
||||||
buildings,
|
|
||||||
effectiveBuildingSelection
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleFloors = useMemo(
|
|
||||||
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
|
|
||||||
[buildings, effectiveBuildingSelection, floors]
|
|
||||||
);
|
|
||||||
const visibleFloorGroups = useMemo(
|
|
||||||
() =>
|
|
||||||
effectiveBuildingSelection === ALL_SELECTION && buildings.length
|
|
||||||
? groupFloorsByBuilding(visibleFloors, buildings)
|
|
||||||
: [{ buildingId: null, label: "", floors: visibleFloors }],
|
|
||||||
[buildings, effectiveBuildingSelection, visibleFloors]
|
|
||||||
);
|
|
||||||
const visibleRooms = useMemo(
|
|
||||||
() =>
|
|
||||||
filterRooms(
|
|
||||||
rooms,
|
|
||||||
floors,
|
|
||||||
buildings,
|
|
||||||
effectiveBuildingSelection,
|
|
||||||
effectiveFloorSelection
|
|
||||||
).sort(compareRooms),
|
|
||||||
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
|
|
||||||
);
|
|
||||||
const roomsInBuildingSelection = useMemo(
|
|
||||||
() =>
|
|
||||||
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
|
|
||||||
.length,
|
|
||||||
[buildings, effectiveBuildingSelection, floors, rooms]
|
|
||||||
);
|
|
||||||
const floorCountByBuilding = useMemo(
|
|
||||||
() => countFloorsByBuilding(floors, buildings),
|
|
||||||
[buildings, floors]
|
|
||||||
);
|
|
||||||
const roomCountByFloor = useMemo(
|
|
||||||
() => countRoomsByFloor(rooms, floors),
|
|
||||||
[floors, rooms]
|
|
||||||
);
|
|
||||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
|
||||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
|
||||||
rooms,
|
|
||||||
floors,
|
|
||||||
effectiveBuildingSelection
|
|
||||||
);
|
|
||||||
const isBusy = isSaving || !project;
|
|
||||||
|
|
||||||
function selectBuilding(selection: StructureSelection) {
|
|
||||||
setBuildingSelection(selection);
|
|
||||||
setFloorSelection(ALL_SELECTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCommand<TResult extends ProjectCommandResultDto>(
|
|
||||||
execute: (expectedRevision: number) => Promise<TResult>,
|
|
||||||
fallbackMessage: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!project) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setIsSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await execute(project.currentRevision);
|
|
||||||
setProject((current) =>
|
|
||||||
current
|
|
||||||
? { ...current, currentRevision: result.history.currentRevision }
|
|
||||||
: current
|
|
||||||
);
|
|
||||||
await reloadStructure();
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : fallbackMessage);
|
|
||||||
// Refresh the revision and lists so a stale state does not block the
|
|
||||||
// next attempt; the command error stays visible.
|
|
||||||
await reloadStructure().catch(() => undefined);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openBuildingDialog(building: BuildingDto | null) {
|
|
||||||
setDialogName(building?.name ?? "");
|
|
||||||
setDialog({ kind: "building", building });
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFloorDialog(floor: FloorDto | null) {
|
|
||||||
setDialogName(floor?.name ?? "");
|
|
||||||
setDialogBuildingId(
|
|
||||||
floor
|
|
||||||
? floor.buildingId ?? ""
|
|
||||||
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
|
|
||||||
);
|
|
||||||
setDialog({ kind: "floor", floor });
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRoomDialog(room: RoomDto | null) {
|
|
||||||
setDialog({
|
|
||||||
kind: "room",
|
|
||||||
room,
|
|
||||||
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (dialog?.kind !== "building" || !dialogName.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const editingBuilding = dialog.building;
|
|
||||||
const name = dialogName.trim();
|
|
||||||
// An unchanged dialog is not a command; the backend rejects no-op updates.
|
|
||||||
if (editingBuilding && editingBuilding.name === name) {
|
|
||||||
setDialog(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const saved = await runCommand(
|
|
||||||
(expectedRevision) =>
|
|
||||||
editingBuilding
|
|
||||||
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
|
|
||||||
: createBuilding(projectId, { name }, expectedRevision),
|
|
||||||
"Gebäude konnte nicht gespeichert werden."
|
|
||||||
);
|
|
||||||
if (saved) {
|
|
||||||
setDialog(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (dialog?.kind !== "floor" || !dialogName.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const editingFloor = dialog.floor;
|
|
||||||
const input = {
|
|
||||||
name: dialogName.trim(),
|
|
||||||
buildingId: dialogBuildingId || null,
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
editingFloor &&
|
|
||||||
editingFloor.name === input.name &&
|
|
||||||
editingFloor.buildingId === input.buildingId
|
|
||||||
) {
|
|
||||||
setDialog(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const saved = await runCommand(
|
|
||||||
(expectedRevision) =>
|
|
||||||
editingFloor
|
|
||||||
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
|
|
||||||
: createFloor(projectId, input, expectedRevision),
|
|
||||||
"Etage konnte nicht gespeichert werden."
|
|
||||||
);
|
|
||||||
if (saved) {
|
|
||||||
setDialog(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSaveRoom(input: CreateRoomInput) {
|
|
||||||
if (dialog?.kind !== "room") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const editingRoom = dialog.room;
|
|
||||||
if (
|
|
||||||
editingRoom &&
|
|
||||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
|
||||||
editingRoom.roomName === input.roomName.trim() &&
|
|
||||||
editingRoom.floorId === (input.floorId || null)
|
|
||||||
) {
|
|
||||||
setDialog(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const saved = await runCommand(
|
|
||||||
(expectedRevision) =>
|
|
||||||
editingRoom
|
|
||||||
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
|
|
||||||
: createRoom(projectId, input, expectedRevision),
|
|
||||||
"Raum konnte nicht gespeichert werden."
|
|
||||||
);
|
|
||||||
if (saved) {
|
|
||||||
setDialog(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteBuilding(building: BuildingDto) {
|
|
||||||
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
|
|
||||||
setError(BUILDING_HAS_FLOORS_MESSAGE);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runCommand(
|
|
||||||
(expectedRevision) =>
|
|
||||||
deleteBuilding(projectId, building.id, expectedRevision),
|
|
||||||
"Gebäude konnte nicht gelöscht werden."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteFloor(floor: FloorDto) {
|
|
||||||
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runCommand(
|
|
||||||
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
|
|
||||||
"Etage konnte nicht gelöscht werden."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteRoom(room: RoomDto) {
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runCommand(
|
|
||||||
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
|
|
||||||
"Raum konnte nicht gelöscht werden."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container py-4">
|
|
||||||
<div className="page-header">
|
|
||||||
<div className="d-flex align-items-start gap-3">
|
|
||||||
<Link
|
|
||||||
aria-label="Zurück zur Projektübersicht"
|
|
||||||
className="btn btn-outline-secondary btn-sm structure-back-link"
|
|
||||||
href={`/projects/${projectId}`}
|
|
||||||
title="Zurück zur Projektübersicht"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" size={18} />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
|
||||||
<h1>Gebäudestruktur</h1>
|
|
||||||
<p className="text-secondary mb-0">
|
|
||||||
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="text-secondary" role="status">
|
|
||||||
Gebäudestruktur wird geladen …
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="structure-columns">
|
|
||||||
<StructureColumn
|
|
||||||
addLabel="Gebäude hinzufügen"
|
|
||||||
count={buildings.length}
|
|
||||||
headingId="structure-buildings-heading"
|
|
||||||
isAddDisabled={isBusy}
|
|
||||||
onAdd={() => openBuildingDialog(null)}
|
|
||||||
title="Gebäude"
|
|
||||||
>
|
|
||||||
<StructureEntry
|
|
||||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
|
||||||
label="Alle"
|
|
||||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
|
||||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
|
||||||
/>
|
|
||||||
{buildings.map((building) => (
|
|
||||||
<StructureEntry
|
|
||||||
actions={
|
|
||||||
<EntryActions
|
|
||||||
deleteLabel={`Gebäude „${building.name}“ löschen`}
|
|
||||||
editLabel={`Gebäude „${building.name}“ bearbeiten`}
|
|
||||||
isDisabled={isBusy}
|
|
||||||
onDelete={() => void handleDeleteBuilding(building)}
|
|
||||||
onEdit={() => openBuildingDialog(building)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isSelected={effectiveBuildingSelection === building.id}
|
|
||||||
key={building.id}
|
|
||||||
label={building.name}
|
|
||||||
meta={formatCount(
|
|
||||||
floorCountByBuilding.get(building.id) ?? 0,
|
|
||||||
"Etage",
|
|
||||||
"Etagen"
|
|
||||||
)}
|
|
||||||
onSelect={() => selectBuilding(building.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{showUnassignedBuilding ? (
|
|
||||||
<StructureEntry
|
|
||||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
|
||||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
|
||||||
meta={formatCount(
|
|
||||||
floorCountByBuilding.get(null) ?? 0,
|
|
||||||
"Etage",
|
|
||||||
"Etagen"
|
|
||||||
)}
|
|
||||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{!buildings.length && !isLoading ? (
|
|
||||||
<li className="list-group-item structure-empty">
|
|
||||||
Noch keine Gebäude vorhanden.
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</StructureColumn>
|
|
||||||
|
|
||||||
<StructureColumn
|
|
||||||
addLabel="Etage hinzufügen"
|
|
||||||
count={visibleFloors.length}
|
|
||||||
headingId="structure-floors-heading"
|
|
||||||
isAddDisabled={isBusy}
|
|
||||||
onAdd={() => openFloorDialog(null)}
|
|
||||||
title="Etagen"
|
|
||||||
>
|
|
||||||
<StructureEntry
|
|
||||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
|
||||||
label="Alle"
|
|
||||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
|
||||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
|
||||||
/>
|
|
||||||
{visibleFloorGroups.map((group) => (
|
|
||||||
<FloorGroupEntries
|
|
||||||
key={group.buildingId ?? "unassigned"}
|
|
||||||
label={group.label}
|
|
||||||
>
|
|
||||||
{group.floors.map((floor) => (
|
|
||||||
<StructureEntry
|
|
||||||
actions={
|
|
||||||
<EntryActions
|
|
||||||
deleteLabel={`Etage „${floor.name}“ löschen`}
|
|
||||||
editLabel={`Etage „${floor.name}“ bearbeiten`}
|
|
||||||
isDisabled={isBusy}
|
|
||||||
onDelete={() => void handleDeleteFloor(floor)}
|
|
||||||
onEdit={() => openFloorDialog(floor)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
isSelected={effectiveFloorSelection === floor.id}
|
|
||||||
key={floor.id}
|
|
||||||
label={floor.name}
|
|
||||||
meta={formatCount(
|
|
||||||
roomCountByFloor.get(floor.id) ?? 0,
|
|
||||||
"Raum",
|
|
||||||
"Räume"
|
|
||||||
)}
|
|
||||||
onSelect={() => setFloorSelection(floor.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FloorGroupEntries>
|
|
||||||
))}
|
|
||||||
{showUnassignedFloor ? (
|
|
||||||
<StructureEntry
|
|
||||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
|
||||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
|
||||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
|
||||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{!visibleFloors.length && !isLoading ? (
|
|
||||||
<li className="list-group-item structure-empty">
|
|
||||||
{floors.length
|
|
||||||
? "Keine Etagen in dieser Auswahl."
|
|
||||||
: "Noch keine Etagen vorhanden."}
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</StructureColumn>
|
|
||||||
|
|
||||||
<StructureColumn
|
|
||||||
addLabel="Raum hinzufügen"
|
|
||||||
count={visibleRooms.length}
|
|
||||||
headingId="structure-rooms-heading"
|
|
||||||
isAddDisabled={isBusy}
|
|
||||||
onAdd={() => openRoomDialog(null)}
|
|
||||||
title="Räume"
|
|
||||||
>
|
|
||||||
{visibleRooms.map((room) => (
|
|
||||||
<StructureEntry
|
|
||||||
actions={
|
|
||||||
<EntryActions
|
|
||||||
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
|
|
||||||
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
|
|
||||||
isDisabled={isBusy}
|
|
||||||
onDelete={() => void handleDeleteRoom(room)}
|
|
||||||
onEdit={() => openRoomDialog(room)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
hint={
|
|
||||||
effectiveFloorSelection === ALL_SELECTION
|
|
||||||
? describeRoomLocation(room, floors, buildings)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
key={room.id}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<span className="structure-room-number">{room.roomNumber}</span>{" "}
|
|
||||||
{room.roomName}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!visibleRooms.length && !isLoading ? (
|
|
||||||
<li className="list-group-item structure-empty">
|
|
||||||
{rooms.length
|
|
||||||
? "Keine Räume in dieser Auswahl."
|
|
||||||
: "Noch keine Räume vorhanden."}
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</StructureColumn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dialog?.kind === "building" ? (
|
|
||||||
<FormModal
|
|
||||||
isSaving={isSaving}
|
|
||||||
onClose={() => setDialog(null)}
|
|
||||||
onSubmit={handleSaveBuilding}
|
|
||||||
submitDisabled={!dialogName.trim()}
|
|
||||||
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
|
|
||||||
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
|
|
||||||
>
|
|
||||||
<label className="form-label" htmlFor="building-name">
|
|
||||||
Bezeichnung
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="form-control"
|
|
||||||
id="building-name"
|
|
||||||
maxLength={200}
|
|
||||||
onChange={(event) => setDialogName(event.target.value)}
|
|
||||||
placeholder="z. B. Haus A"
|
|
||||||
required
|
|
||||||
value={dialogName}
|
|
||||||
/>
|
|
||||||
</FormModal>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dialog?.kind === "floor" ? (
|
|
||||||
<FormModal
|
|
||||||
isSaving={isSaving}
|
|
||||||
onClose={() => setDialog(null)}
|
|
||||||
onSubmit={handleSaveFloor}
|
|
||||||
submitDisabled={!dialogName.trim()}
|
|
||||||
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
|
|
||||||
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
|
||||||
>
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12 col-md-6">
|
|
||||||
<label className="form-label" htmlFor="floor-name">
|
|
||||||
Bezeichnung
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="form-control"
|
|
||||||
id="floor-name"
|
|
||||||
maxLength={200}
|
|
||||||
onChange={(event) => setDialogName(event.target.value)}
|
|
||||||
placeholder="z. B. EG"
|
|
||||||
required
|
|
||||||
value={dialogName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-6">
|
|
||||||
<label className="form-label" htmlFor="floor-building">
|
|
||||||
Gebäude
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
id="floor-building"
|
|
||||||
onChange={(event) => setDialogBuildingId(event.target.value)}
|
|
||||||
value={dialogBuildingId}
|
|
||||||
>
|
|
||||||
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
|
|
||||||
{buildings.map((building) => (
|
|
||||||
<option key={building.id} value={building.id}>
|
|
||||||
{building.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{dialog?.kind === "room" ? (
|
|
||||||
<RoomModal
|
|
||||||
buildings={buildings}
|
|
||||||
floors={floors}
|
|
||||||
initialRoom={dialog.room ?? undefined}
|
|
||||||
initialValues={
|
|
||||||
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
|
|
||||||
}
|
|
||||||
isSaving={isSaving}
|
|
||||||
onClose={() => setDialog(null)}
|
|
||||||
onSave={handleSaveRoom}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FloorGroupEntries({
|
|
||||||
children,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{label ? (
|
|
||||||
<li className="list-group-item structure-group-label">
|
|
||||||
{label}
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,14 +2,16 @@
|
||||||
// the module is an optional calculator attached to the circuit list and never
|
// the module is an optional calculator attached to the circuit list and never
|
||||||
// writes project data itself (see docs/cable-sizing-module.md).
|
// writes project data itself (see docs/cable-sizing-module.md).
|
||||||
//
|
//
|
||||||
// Reference data: DIN VDE 0298-4 (VDE 0298-4):2023-06 together with
|
// Reference data: IEC 60364-5-52:2009, Annex B (tables B.52.2 to B.52.5,
|
||||||
// Berichtigung 1:2023-10. The table-by-table scope and the separate sources
|
// B.52.10 to B.52.19). Every value was cross-checked against publicly
|
||||||
// required for voltage drop and protection coordination are documented in
|
// available reproductions of these tables; where sources disagreed the lower,
|
||||||
// docs/cable-sizing-module.md.
|
// i.e. safer, value is used. The table-by-table verification record is in
|
||||||
|
// docs/cable-sizing-module.md. National tables (DIN VDE 0298-4) may differ in
|
||||||
|
// individual values and must be checked by the planner against the norm text.
|
||||||
|
|
||||||
// -- Reference installation methods (DIN VDE 0298-4, Table 2) ---------------
|
// -- Reference installation methods (IEC 60364-5-52, Table B.52.1) ----------
|
||||||
|
|
||||||
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D", "E"] as const;
|
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D1", "D2", "E"] as const;
|
||||||
export type LayingMethod = (typeof LAYING_METHODS)[number];
|
export type LayingMethod = (typeof LAYING_METHODS)[number];
|
||||||
|
|
||||||
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
|
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
|
||||||
|
|
@ -19,7 +21,8 @@ export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
|
||||||
B2: "air",
|
B2: "air",
|
||||||
C: "air",
|
C: "air",
|
||||||
E: "air",
|
E: "air",
|
||||||
D: "ground",
|
D1: "ground",
|
||||||
|
D2: "ground",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
|
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
|
||||||
|
|
@ -28,7 +31,8 @@ export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
|
||||||
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
|
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
|
||||||
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
|
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
|
||||||
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
|
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
|
||||||
D: "D – Mehradriges Kabel im Rohr oder Kanal im Erdreich",
|
D1: "D1 – Leitung im Rohr oder Kabelschutzrohr im Erdreich",
|
||||||
|
D2: "D2 – Kabel direkt im Erdreich",
|
||||||
E: "E – Mehradrige Leitung frei in Luft",
|
E: "E – Mehradrige Leitung frei in Luft",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -45,7 +49,7 @@ export const INSULATION_MATERIAL_LABELS: Record<InsulationMaterial, string> = {
|
||||||
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
|
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
|
||||||
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
|
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
|
||||||
|
|
||||||
// -- Harmonic neutral loading (DIN VDE 0298-4, Annex B, three-phase only) ---
|
// -- Harmonic neutral loading (IEC 60364-5-52, Annex E, three-phase only) ---
|
||||||
|
|
||||||
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
|
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
|
||||||
"none",
|
"none",
|
||||||
|
|
@ -57,7 +61,7 @@ export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number]
|
||||||
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
|
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
|
||||||
none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall",
|
none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall",
|
||||||
"15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86",
|
"15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86",
|
||||||
over33Percent: "3. Harmonische > 33 % – ohne Neutralleiterstrom keine Empfehlung",
|
over33Percent: "3. Harmonische > 33 % – Neutralleiter wie Außenleiter dimensionieren",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HARMONIC_REDUCTION_FACTOR = 0.86;
|
export const HARMONIC_REDUCTION_FACTOR = 0.86;
|
||||||
|
|
@ -81,13 +85,14 @@ export const CURRENT_CAPACITY_A: Record<
|
||||||
> = {
|
> = {
|
||||||
pvc: {
|
pvc: {
|
||||||
copper: {
|
copper: {
|
||||||
// DIN VDE 0298-4, tables 3 and 4.
|
// B.52.2 / B.52.4; E: B.52.10 multi-core. A1 2L 240 mm²: sources 320/321 → 320.
|
||||||
|
// E 3L 70/120 mm²: sources 196/198 and 276/278 → 196 and 276.
|
||||||
A1: {
|
A1: {
|
||||||
2: [15.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 321],
|
2: [14.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 320],
|
||||||
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
|
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
|
||||||
},
|
},
|
||||||
A2: {
|
A2: {
|
||||||
2: [15.5, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
|
2: [14, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
|
||||||
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
|
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
|
||||||
},
|
},
|
||||||
B1: {
|
B1: {
|
||||||
|
|
@ -102,18 +107,22 @@ export const CURRENT_CAPACITY_A: Record<
|
||||||
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
|
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
|
||||||
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
|
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
|
||||||
},
|
},
|
||||||
D: {
|
D1: {
|
||||||
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
|
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
|
||||||
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
|
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
|
||||||
},
|
},
|
||||||
|
D2: {
|
||||||
|
2: [22, 28, 38, 48, 64, 83, 110, 132, 156, 192, 230, 261, 293, 331, 382],
|
||||||
|
3: [19, 24, 33, 41, 54, 70, 92, 110, 130, 162, 193, 220, 246, 278, 320],
|
||||||
|
},
|
||||||
E: {
|
E: {
|
||||||
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
|
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
|
||||||
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
|
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
aluminum: {
|
aluminum: {
|
||||||
// DIN VDE 0298-4 aluminium values; the normalization below removes all
|
// B.52.2 / B.52.4 aluminium; E: B.52.11. No values below 2.5 mm²
|
||||||
// entries below the first tabulated aluminium size of 25 mm².
|
// (D2 below 16 mm²).
|
||||||
A1: {
|
A1: {
|
||||||
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
|
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
|
||||||
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
|
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
|
||||||
|
|
@ -134,10 +143,14 @@ export const CURRENT_CAPACITY_A: Record<
|
||||||
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
|
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
|
||||||
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
|
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
|
||||||
},
|
},
|
||||||
D: {
|
D1: {
|
||||||
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
|
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
|
||||||
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
|
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
|
||||||
},
|
},
|
||||||
|
D2: {
|
||||||
|
2: [n, n, n, n, n, 63, 82, 98, 117, 145, 173, 200, 224, 255, 298],
|
||||||
|
3: [n, n, n, n, n, 53, 69, 83, 99, 122, 148, 169, 189, 214, 250],
|
||||||
|
},
|
||||||
E: {
|
E: {
|
||||||
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
|
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
|
||||||
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
|
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
|
||||||
|
|
@ -167,10 +180,14 @@ export const CURRENT_CAPACITY_A: Record<
|
||||||
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
|
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
|
||||||
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
|
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
|
||||||
},
|
},
|
||||||
D: {
|
D1: {
|
||||||
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
|
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
|
||||||
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
|
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
|
||||||
},
|
},
|
||||||
|
D2: {
|
||||||
|
2: [27, 35, 46, 58, 77, 100, 129, 155, 183, 225, 270, 306, 343, 387, 448],
|
||||||
|
3: [23, 30, 39, 49, 65, 84, 107, 129, 153, 188, 226, 257, 287, 324, 375],
|
||||||
|
},
|
||||||
E: {
|
E: {
|
||||||
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
|
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
|
||||||
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
|
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
|
||||||
|
|
@ -198,10 +215,14 @@ export const CURRENT_CAPACITY_A: Record<
|
||||||
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
|
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
|
||||||
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
|
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
|
||||||
},
|
},
|
||||||
D: {
|
D1: {
|
||||||
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
|
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
|
||||||
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
|
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
|
||||||
},
|
},
|
||||||
|
D2: {
|
||||||
|
2: [n, n, n, n, n, 76, 98, 117, 139, 170, 204, 233, 261, 296, 343],
|
||||||
|
3: [n, n, n, n, n, 64, 82, 98, 117, 144, 172, 197, 220, 250, 290],
|
||||||
|
},
|
||||||
E: {
|
E: {
|
||||||
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
|
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
|
||||||
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
|
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
|
||||||
|
|
@ -210,25 +231,9 @@ export const CURRENT_CAPACITY_A: Record<
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// DIN VDE 0298-4 only supplies aluminium current-carrying capacities from
|
|
||||||
// 25 mm² onward. Smaller values from the former IEC data set must therefore
|
|
||||||
// never participate in a recommendation.
|
|
||||||
for (const insulation of INSULATION_MATERIALS) {
|
|
||||||
for (const method of LAYING_METHODS) {
|
|
||||||
for (const coreCount of [2, 3] as const) {
|
|
||||||
const row = CURRENT_CAPACITY_A[insulation].aluminum[method][coreCount] as (number | null)[];
|
|
||||||
CROSS_SECTIONS_MM2.forEach((crossSection, index) => {
|
|
||||||
if (crossSection < 25) {
|
|
||||||
row[index] = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Correction factors ------------------------------------------------------
|
// -- Correction factors ------------------------------------------------------
|
||||||
|
|
||||||
/** DIN VDE 0298-4 tables 17 (air) and 20 (ground). */
|
/** B.52.14 (air, reference 30 °C) and B.52.15 (ground, reference 20 °C). */
|
||||||
export const TEMPERATURE_FACTORS: Record<
|
export const TEMPERATURE_FACTORS: Record<
|
||||||
"air" | "ground",
|
"air" | "ground",
|
||||||
Record<InsulationMaterial, Record<number, number>>
|
Record<InsulationMaterial, Record<number, number>>
|
||||||
|
|
@ -255,32 +260,26 @@ export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grouping reduction factors according to the corrected references in
|
* Grouping reduction factors for touching circuits/cables:
|
||||||
* DIN VDE 0298-4 Berichtigung 1:2023-10:
|
* air (A1–C, E): B.52.17 item 1 "bunched in air, on a surface, embedded or
|
||||||
* A1–C use table 22, row 1 (bundled/touching), D uses table 26 for touching
|
* enclosed" – the most restrictive arrangement, also used for E (single
|
||||||
* conduits/ducts in earth. Method E needs details about support, spacing and
|
* layers on trays would allow higher factors);
|
||||||
* number of trays from table 23, which the current input model does not carry;
|
* D1: B.52.19 A (multi-core cables in touching ducts);
|
||||||
* it is therefore supported only for one circuit.
|
* D2: B.52.18 (cables direct in ground, touching).
|
||||||
*/
|
*/
|
||||||
export const GROUPING_FACTORS: Record<"A1_TO_C" | "D" | "E", Record<number, number>> = {
|
export const GROUPING_FACTORS: Record<"air" | "D1" | "D2", Record<number, number>> = {
|
||||||
A1_TO_C: {
|
air: { 1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52, 9: 0.5, 12: 0.45, 16: 0.41, 20: 0.38 },
|
||||||
1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52,
|
D1: {
|
||||||
9: 0.5, 10: 0.48, 12: 0.45, 14: 0.43, 16: 0.41, 18: 0.39, 20: 0.38,
|
1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6, 7: 0.57, 8: 0.54, 9: 0.52, 10: 0.49,
|
||||||
|
11: 0.47, 12: 0.45, 13: 0.44, 14: 0.42, 15: 0.41, 16: 0.39, 17: 0.38, 18: 0.37, 19: 0.35, 20: 0.34,
|
||||||
},
|
},
|
||||||
D: { 1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6 },
|
D2: { 1: 1.0, 2: 0.75, 3: 0.65, 4: 0.6, 5: 0.55, 6: 0.5, 7: 0.45, 8: 0.43, 9: 0.41, 12: 0.36, 16: 0.32, 20: 0.29 },
|
||||||
E: { 1: 1.0 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function groupingTableFor(method: LayingMethod) {
|
export const MAX_GROUPING_CIRCUITS = 20;
|
||||||
return method === "D"
|
|
||||||
? GROUPING_FACTORS.D
|
|
||||||
: method === "E"
|
|
||||||
? GROUPING_FACTORS.E
|
|
||||||
: GROUPING_FACTORS.A1_TO_C;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maxGroupingCircuits(method: LayingMethod): number {
|
function groupingTableFor(method: LayingMethod) {
|
||||||
return Math.max(...Object.keys(groupingTableFor(method)).map(Number));
|
return method === "D1" ? GROUPING_FACTORS.D1 : method === "D2" ? GROUPING_FACTORS.D2 : GROUPING_FACTORS.air;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -374,7 +373,7 @@ export interface CableSizingInput {
|
||||||
layingMethod: LayingMethod;
|
layingMethod: LayingMethod;
|
||||||
conductorMaterial: ConductorMaterial;
|
conductorMaterial: ConductorMaterial;
|
||||||
insulation: InsulationMaterial;
|
insulation: InsulationMaterial;
|
||||||
/** Ambient air temperature, or ground temperature for D. */
|
/** Ambient air temperature, or ground temperature for D1/D2. */
|
||||||
ambientTemperatureC: number;
|
ambientTemperatureC: number;
|
||||||
/** Number of grouped circuits or multi-core cables, including this one. */
|
/** Number of grouped circuits or multi-core cables, including this one. */
|
||||||
groupingCircuits: number;
|
groupingCircuits: number;
|
||||||
|
|
@ -406,7 +405,7 @@ export interface ProtectionCoordinationResult {
|
||||||
coordinated: boolean;
|
coordinated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UnsupportedReason = "temperature" | "grouping" | "harmonics";
|
export type UnsupportedReason = "temperature" | "grouping";
|
||||||
|
|
||||||
export interface CableSizingResult {
|
export interface CableSizingResult {
|
||||||
/** false: the inputs are outside the tabulated range – no numeric result. */
|
/** false: the inputs are outside the tabulated range – no numeric result. */
|
||||||
|
|
@ -455,23 +454,13 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
input.ambientTemperatureC
|
input.ambientTemperatureC
|
||||||
);
|
);
|
||||||
const grouping =
|
const grouping =
|
||||||
input.groupingCircuits <= maxGroupingCircuits(input.layingMethod)
|
input.groupingCircuits <= MAX_GROUPING_CIRCUITS
|
||||||
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
|
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
|
||||||
: null;
|
: null;
|
||||||
const harmonicsSupported = !(
|
|
||||||
input.phase === 3 && input.harmonicNeutralLoad === "over33Percent"
|
|
||||||
);
|
|
||||||
const unsupportedReason: UnsupportedReason | null = !temperature
|
|
||||||
? "temperature"
|
|
||||||
: !grouping
|
|
||||||
? "grouping"
|
|
||||||
: !harmonicsSupported
|
|
||||||
? "harmonics"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const empty: CableSizingResult = {
|
const empty: CableSizingResult = {
|
||||||
supported: false,
|
supported: false,
|
||||||
unsupportedReason,
|
unsupportedReason: !temperature ? "temperature" : "grouping",
|
||||||
operatingCurrentA: ib,
|
operatingCurrentA: ib,
|
||||||
designCurrentA,
|
designCurrentA,
|
||||||
temperatureUsedC: temperature?.key ?? null,
|
temperatureUsedC: temperature?.key ?? null,
|
||||||
|
|
@ -488,7 +477,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
rows: [],
|
rows: [],
|
||||||
protectionCoordination: null,
|
protectionCoordination: null,
|
||||||
};
|
};
|
||||||
if (unsupportedReason || !temperature || !grouping) {
|
if (!temperature || !grouping) {
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -597,17 +586,12 @@ export function buildCableSizingAlerts(input: CableSizingInput, result: CableSiz
|
||||||
const group = LAYING_METHOD_GROUP[input.layingMethod];
|
const group = LAYING_METHOD_GROUP[input.layingMethod];
|
||||||
|
|
||||||
if (!result.supported) {
|
if (!result.supported) {
|
||||||
const groupingMaximum = maxGroupingCircuits(input.layingMethod);
|
|
||||||
alerts.push({
|
alerts.push({
|
||||||
kind: "critical",
|
kind: "critical",
|
||||||
text:
|
text:
|
||||||
result.unsupportedReason === "temperature"
|
result.unsupportedReason === "temperature"
|
||||||
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
|
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
|
||||||
: result.unsupportedReason === "harmonics"
|
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt außerhalb der Tabelle (max. ${MAX_GROUPING_CIRCUITS}) – keine Berechnung möglich.`,
|
||||||
? "Bei mehr als 33 % dritter Harmonischer muss nach dem Neutralleiterstrom bemessen werden. Ohne diesen Wert wird keine Querschnittsempfehlung ausgegeben."
|
|
||||||
: input.layingMethod === "E"
|
|
||||||
? "Für Verlegeart E benötigt die Norm bei Häufung Angaben zu Trägerart, Abstand und Anzahl der Kabelpritschen. Ohne diese Angaben ist nur ein einzelner Stromkreis berechenbar."
|
|
||||||
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt für Verlegeart ${input.layingMethod} außerhalb der verwendeten Tabelle (max. ${groupingMaximum}) – keine Berechnung möglich.`,
|
|
||||||
});
|
});
|
||||||
return alerts;
|
return alerts;
|
||||||
}
|
}
|
||||||
|
|
@ -653,10 +637,10 @@ export function buildCableSizingAlerts(input: CableSizingInput, result: CableSiz
|
||||||
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
|
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (input.lengthM === 0) {
|
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
kind: "info",
|
kind: "warn",
|
||||||
text: "Leitungslänge 0 m: Für diese Berechnung wird kein Spannungsfall angesetzt.",
|
text: "3. Harmonische > 33 %: Der Neutralleiter ist nach dem Neutralleiterstrom zu bemessen – hier nicht berücksichtigt.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (group === "ground") {
|
if (group === "ground") {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
CREATE TABLE `document_branding_assets` (
|
|
||||||
`key` text PRIMARY KEY NOT NULL,
|
|
||||||
`mime_type` text NOT NULL,
|
|
||||||
`file_name` text,
|
|
||||||
`data` blob NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `remark` text;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
CREATE TABLE `document_branding_settings` (
|
|
||||||
`key` text PRIMARY KEY NOT NULL,
|
|
||||||
`value` text NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `project_editor` text;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
CREATE TABLE `buildings` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
CREATE TABLE `estimate_balances` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`work_stage` text,
|
|
||||||
`date_iso` text,
|
|
||||||
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
|
||||||
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `estimate_groups` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`balance_id` text NOT NULL,
|
|
||||||
`cost_group` text DEFAULT '' NOT NULL,
|
|
||||||
`title` text NOT NULL,
|
|
||||||
`editor` text,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `estimate_rows` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`group_id` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
`supply` text,
|
|
||||||
`name` text DEFAULT '' NOT NULL,
|
|
||||||
`quantity` real DEFAULT 0 NOT NULL,
|
|
||||||
`unit` text DEFAULT 'piece' NOT NULL,
|
|
||||||
`connection` text,
|
|
||||||
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
|
||||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
|
||||||
`cos_phi` real DEFAULT 1 NOT NULL,
|
|
||||||
`safety_supply` integer DEFAULT false NOT NULL,
|
|
||||||
`remark` text,
|
|
||||||
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -50,34 +50,6 @@
|
||||||
"when": 1786043080323,
|
"when": 1786043080323,
|
||||||
"tag": "0006_damp_skrulls",
|
"tag": "0006_damp_skrulls",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 7,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790411540872,
|
|
||||||
"tag": "0007_board_info_and_document_logo",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 8,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790411560953,
|
|
||||||
"tag": "0008_project_cover_and_office_profile",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 9,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790419185882,
|
|
||||||
"tag": "0009_buildings",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790438092269,
|
|
||||||
"tag": "0010_estimate_balances",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { buildings } from "../schema/buildings.js";
|
|
||||||
|
|
||||||
export class BuildingRepository {
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
async listByProject(projectId: string) {
|
|
||||||
return this.database
|
|
||||||
.select()
|
|
||||||
.from(buildings)
|
|
||||||
.where(eq(buildings.projectId, projectId))
|
|
||||||
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(projectId: string, buildingId: string) {
|
|
||||||
return (
|
|
||||||
this.database
|
|
||||||
.select()
|
|
||||||
.from(buildings)
|
|
||||||
.where(
|
|
||||||
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
|
|
||||||
)
|
|
||||||
.get() ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -388,15 +388,10 @@ function structureMatches(
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
simultaneityFactor,
|
simultaneityFactor,
|
||||||
boardNumber,
|
|
||||||
location,
|
|
||||||
fedFrom,
|
|
||||||
remark,
|
|
||||||
...actualDistributionBoard
|
...actualDistributionBoard
|
||||||
} = actual.board;
|
} = actual.board;
|
||||||
if (
|
if (
|
||||||
simultaneityFactor !== 1 ||
|
simultaneityFactor !== 1 ||
|
||||||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
|
|
||||||
!sameRecord(
|
!sameRecord(
|
||||||
expected.distributionBoard,
|
expected.distributionBoard,
|
||||||
actualDistributionBoard
|
actualDistributionBoard
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
|
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertDistributionBoardSubtreeSnapshot,
|
assertDistributionBoardSubtreeSnapshot,
|
||||||
normalizeDistributionBoardSubtreeSnapshot,
|
|
||||||
type DistributionBoardSubtreeSnapshot,
|
type DistributionBoardSubtreeSnapshot,
|
||||||
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
|
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -64,20 +63,19 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
projectId: string,
|
projectId: string,
|
||||||
command: DistributionBoardSubtreeProjectCommand
|
command: DistributionBoardSubtreeProjectCommand
|
||||||
) {
|
) {
|
||||||
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
|
|
||||||
command.payload.snapshot
|
|
||||||
);
|
|
||||||
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
||||||
this.insert(database, projectId, snapshot);
|
this.insert(database, projectId, command.payload.snapshot);
|
||||||
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
|
return createDistributionBoardDeleteSubtreeProjectCommand(
|
||||||
|
command.payload.snapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (command.type === distributionBoardDeleteSubtreeCommandType) {
|
if (command.type === distributionBoardDeleteSubtreeCommandType) {
|
||||||
const current = this.captureFrom(
|
const current = this.captureFrom(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
snapshot.distributionBoard.id
|
command.payload.snapshot.distributionBoard.id
|
||||||
);
|
);
|
||||||
if (!sameSnapshot(current, snapshot)) {
|
if (!sameSnapshot(current, command.payload.snapshot)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Distribution-board subtree changed before deletion."
|
"Distribution-board subtree changed before deletion."
|
||||||
);
|
);
|
||||||
|
|
@ -99,7 +97,9 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
"Distribution-board subtree could not be deleted."
|
"Distribution-board subtree could not be deleted."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
|
return createDistributionBoardInsertSubtreeProjectCommand(
|
||||||
|
command.payload.snapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported distribution-board subtree command.");
|
throw new Error("Unsupported distribution-board subtree command.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import {
|
|
||||||
documentBrandingAssets,
|
|
||||||
type DocumentBrandingAsset,
|
|
||||||
} from "../schema/document-branding-assets.js";
|
|
||||||
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
|
|
||||||
|
|
||||||
// Plain repository without revision semantics: branding is global office
|
|
||||||
// configuration, not a project mutation.
|
|
||||||
export class DocumentBrandingRepository {
|
|
||||||
constructor(private readonly db: AppDatabase) {}
|
|
||||||
|
|
||||||
get(key: string): DocumentBrandingAsset | undefined {
|
|
||||||
return this.db
|
|
||||||
.select()
|
|
||||||
.from(documentBrandingAssets)
|
|
||||||
.where(eq(documentBrandingAssets.key, key))
|
|
||||||
.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert(asset: {
|
|
||||||
key: string;
|
|
||||||
mimeType: string;
|
|
||||||
fileName: string | null;
|
|
||||||
data: Buffer;
|
|
||||||
}): DocumentBrandingAsset {
|
|
||||||
const updatedAt = new Date();
|
|
||||||
return this.db
|
|
||||||
.insert(documentBrandingAssets)
|
|
||||||
.values({ ...asset, updatedAt })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: documentBrandingAssets.key,
|
|
||||||
set: {
|
|
||||||
mimeType: asset.mimeType,
|
|
||||||
fileName: asset.fileName,
|
|
||||||
data: asset.data,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key: string): boolean {
|
|
||||||
return (
|
|
||||||
this.db
|
|
||||||
.delete(documentBrandingAssets)
|
|
||||||
.where(eq(documentBrandingAssets.key, key))
|
|
||||||
.run().changes > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
|
|
||||||
const rows = this.db
|
|
||||||
.select()
|
|
||||||
.from(documentBrandingSettings)
|
|
||||||
.where(inArray(documentBrandingSettings.key, [...keys]))
|
|
||||||
.all();
|
|
||||||
return Object.fromEntries(
|
|
||||||
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
|
|
||||||
) as Record<TKey, string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stores the given values atomically; `null` removes a setting. */
|
|
||||||
setSettings(values: Record<string, string | null>) {
|
|
||||||
const updatedAt = new Date();
|
|
||||||
this.db.transaction((tx) => {
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (value === null) {
|
|
||||||
tx.delete(documentBrandingSettings)
|
|
||||||
.where(eq(documentBrandingSettings.key, key))
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
tx.insert(documentBrandingSettings)
|
|
||||||
.values({ key, value, updatedAt })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: documentBrandingSettings.key,
|
|
||||||
set: { value, updatedAt },
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
assertEstimateBalanceDeleteProjectCommand,
|
|
||||||
assertEstimateBalanceInsertProjectCommand,
|
|
||||||
assertEstimateBalanceUpdateProjectCommand,
|
|
||||||
createEstimateBalanceDeleteProjectCommand,
|
|
||||||
createEstimateBalanceInsertProjectCommand,
|
|
||||||
createEstimateBalanceUpdateProjectCommand,
|
|
||||||
estimateBalanceDeleteCommandType,
|
|
||||||
estimateBalanceInsertCommandType,
|
|
||||||
estimateBalanceUpdateCommandType,
|
|
||||||
normalizeEstimateBalanceSnapshot,
|
|
||||||
sameEstimateBalance,
|
|
||||||
type EstimateBalanceProjectCommand,
|
|
||||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
|
||||||
import type {
|
|
||||||
EstimateBalanceProjectCommandStore,
|
|
||||||
ExecuteEstimateBalanceCommandInput,
|
|
||||||
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
|
|
||||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { estimateBalances } from "../schema/estimate-balances.js";
|
|
||||||
import { projects } from "../schema/projects.js";
|
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
|
||||||
import {
|
|
||||||
deleteEstimateBalance,
|
|
||||||
insertEstimateBalance,
|
|
||||||
readEstimateBalances,
|
|
||||||
} from "./estimate-balance.persistence.js";
|
|
||||||
|
|
||||||
export class EstimateBalanceProjectCommandRepository
|
|
||||||
implements EstimateBalanceProjectCommandStore
|
|
||||||
{
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
execute(input: ExecuteEstimateBalanceCommandInput) {
|
|
||||||
return executeProjectCommandTransaction(this.database, input, (tx) =>
|
|
||||||
this.applyCommand(tx, input.projectId, input.command)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyCommand(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
command: EstimateBalanceProjectCommand
|
|
||||||
): EstimateBalanceProjectCommand {
|
|
||||||
switch (command.type) {
|
|
||||||
case estimateBalanceInsertCommandType:
|
|
||||||
assertEstimateBalanceInsertProjectCommand(command);
|
|
||||||
return this.insert(database, projectId, command.payload.balance);
|
|
||||||
case estimateBalanceDeleteCommandType:
|
|
||||||
assertEstimateBalanceDeleteProjectCommand(command);
|
|
||||||
return this.delete(database, projectId, command.payload.balance);
|
|
||||||
case estimateBalanceUpdateCommandType:
|
|
||||||
assertEstimateBalanceUpdateProjectCommand(command);
|
|
||||||
return this.update(
|
|
||||||
database,
|
|
||||||
projectId,
|
|
||||||
command.payload.expected,
|
|
||||||
command.payload.target
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private insert(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
) {
|
|
||||||
if (balance.projectId !== projectId) {
|
|
||||||
throw new Error("Estimate-balance snapshot does not belong to project.");
|
|
||||||
}
|
|
||||||
const project = database
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(eq(projects.id, projectId))
|
|
||||||
.get();
|
|
||||||
if (!project) {
|
|
||||||
throw new Error("Project does not exist.");
|
|
||||||
}
|
|
||||||
const existing = database
|
|
||||||
.select({ id: estimateBalances.id })
|
|
||||||
.from(estimateBalances)
|
|
||||||
.where(eq(estimateBalances.id, balance.id))
|
|
||||||
.get();
|
|
||||||
if (existing) {
|
|
||||||
throw new Error("Estimate-balance id already exists.");
|
|
||||||
}
|
|
||||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
|
||||||
insertEstimateBalance(database, normalized);
|
|
||||||
return createEstimateBalanceDeleteProjectCommand(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
private delete(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
expected: EstimateBalanceSnapshot
|
|
||||||
) {
|
|
||||||
this.assertPersisted(database, projectId, expected, "deletion");
|
|
||||||
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
|
|
||||||
throw new Error("Estimate balance could not be deleted.");
|
|
||||||
}
|
|
||||||
return createEstimateBalanceInsertProjectCommand(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
private update(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
expected: EstimateBalanceSnapshot,
|
|
||||||
target: EstimateBalanceSnapshot
|
|
||||||
) {
|
|
||||||
this.assertPersisted(database, projectId, expected, "update");
|
|
||||||
// Group and row ids stay stable; replacing the whole document keeps the
|
|
||||||
// command exact and its inverse trivially the swapped pair.
|
|
||||||
deleteEstimateBalance(database, projectId, expected.id);
|
|
||||||
const normalized = normalizeEstimateBalanceSnapshot(target);
|
|
||||||
insertEstimateBalance(database, normalized);
|
|
||||||
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertPersisted(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
expected: EstimateBalanceSnapshot,
|
|
||||||
action: string
|
|
||||||
) {
|
|
||||||
const [persisted] = readEstimateBalances(database, projectId, expected.id);
|
|
||||||
if (!persisted || !sameEstimateBalance(persisted, expected)) {
|
|
||||||
throw new Error(`Estimate balance changed before ${action}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
||||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import {
|
|
||||||
estimateBalances,
|
|
||||||
estimateGroups,
|
|
||||||
estimateRows,
|
|
||||||
} from "../schema/estimate-balances.js";
|
|
||||||
|
|
||||||
/** Reads complete balance documents of a project, ordered like the UI shows them. */
|
|
||||||
export function readEstimateBalances(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
balanceId?: string
|
|
||||||
): EstimateBalanceSnapshot[] {
|
|
||||||
const balances = database
|
|
||||||
.select()
|
|
||||||
.from(estimateBalances)
|
|
||||||
.where(
|
|
||||||
balanceId
|
|
||||||
? and(
|
|
||||||
eq(estimateBalances.projectId, projectId),
|
|
||||||
eq(estimateBalances.id, balanceId)
|
|
||||||
)
|
|
||||||
: eq(estimateBalances.projectId, projectId)
|
|
||||||
)
|
|
||||||
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
|
|
||||||
.all();
|
|
||||||
if (!balances.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const groups = database
|
|
||||||
.select()
|
|
||||||
.from(estimateGroups)
|
|
||||||
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
|
|
||||||
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
|
|
||||||
.all();
|
|
||||||
const rows = groups.length
|
|
||||||
? database
|
|
||||||
.select()
|
|
||||||
.from(estimateRows)
|
|
||||||
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
|
|
||||||
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
|
|
||||||
.all()
|
|
||||||
: [];
|
|
||||||
return balances.map((balance) => ({
|
|
||||||
id: balance.id,
|
|
||||||
projectId: balance.projectId,
|
|
||||||
name: balance.name,
|
|
||||||
workStage: balance.workStage,
|
|
||||||
dateIso: balance.dateIso,
|
|
||||||
reserveAvPercent: balance.reserveAvPercent,
|
|
||||||
reserveSvPercent: balance.reserveSvPercent,
|
|
||||||
sortOrder: balance.sortOrder,
|
|
||||||
groups: groups
|
|
||||||
.filter((group) => group.balanceId === balance.id)
|
|
||||||
.map((group) => ({
|
|
||||||
id: group.id,
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: group.editor,
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
rows: rows
|
|
||||||
.filter((row) => row.groupId === group.id)
|
|
||||||
.map(({ groupId: _groupId, ...row }) => row),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Writes a complete balance document (the caller removed an older version first). */
|
|
||||||
export function insertEstimateBalance(
|
|
||||||
database: AppDatabase,
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
) {
|
|
||||||
database
|
|
||||||
.insert(estimateBalances)
|
|
||||||
.values({
|
|
||||||
id: balance.id,
|
|
||||||
projectId: balance.projectId,
|
|
||||||
name: balance.name,
|
|
||||||
workStage: balance.workStage,
|
|
||||||
dateIso: balance.dateIso,
|
|
||||||
reserveAvPercent: balance.reserveAvPercent,
|
|
||||||
reserveSvPercent: balance.reserveSvPercent,
|
|
||||||
sortOrder: balance.sortOrder,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
for (const group of balance.groups) {
|
|
||||||
database
|
|
||||||
.insert(estimateGroups)
|
|
||||||
.values({
|
|
||||||
id: group.id,
|
|
||||||
balanceId: balance.id,
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: group.editor,
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
if (group.rows.length) {
|
|
||||||
database
|
|
||||||
.insert(estimateRows)
|
|
||||||
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Removes a balance with its groups and rows (foreign keys cascade). */
|
|
||||||
export function deleteEstimateBalance(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
balanceId: string
|
|
||||||
) {
|
|
||||||
return database
|
|
||||||
.delete(estimateBalances)
|
|
||||||
.where(
|
|
||||||
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
|
|
||||||
)
|
|
||||||
.run().changes;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { asc, eq } from "drizzle-orm";
|
|
||||||
import type {
|
|
||||||
DetailedDeviceRowPower,
|
|
||||||
EstimateBalanceSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
|
||||||
import { circuits } from "../schema/circuits.js";
|
|
||||||
import { estimateBalances } from "../schema/estimate-balances.js";
|
|
||||||
import { readEstimateBalances } from "./estimate-balance.persistence.js";
|
|
||||||
|
|
||||||
/** Read side of the estimate-balance module; writes go through commands. */
|
|
||||||
export class EstimateBalanceRepository {
|
|
||||||
constructor(private readonly database: AppDatabase) {}
|
|
||||||
|
|
||||||
listByProject(projectId: string): EstimateBalanceSnapshot[] {
|
|
||||||
return readEstimateBalances(this.database, projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
|
|
||||||
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextSortOrder(projectId: string): number {
|
|
||||||
const rows = this.database
|
|
||||||
.select({ sortOrder: estimateBalances.sortOrder })
|
|
||||||
.from(estimateBalances)
|
|
||||||
.where(eq(estimateBalances.projectId, projectId))
|
|
||||||
.orderBy(asc(estimateBalances.sortOrder))
|
|
||||||
.all();
|
|
||||||
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device rows of all circuit lists of the project with their total power
|
|
||||||
* (quantity · power per unit · device GZF, like the circuit tree).
|
|
||||||
*/
|
|
||||||
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
|
|
||||||
return this.database
|
|
||||||
.select({
|
|
||||||
costGroup: circuitDeviceRows.costGroup,
|
|
||||||
quantity: circuitDeviceRows.quantity,
|
|
||||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
|
||||||
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
|
||||||
cosPhi: circuitDeviceRows.cosPhi,
|
|
||||||
})
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
|
||||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
|
||||||
.where(eq(circuitLists.projectId, projectId))
|
|
||||||
.all()
|
|
||||||
.map((row) => ({
|
|
||||||
costGroup: row.costGroup,
|
|
||||||
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
|
||||||
cosPhi: row.cosPhi,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +1,23 @@
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
assertProjectBuildingDeleteProjectCommand,
|
|
||||||
assertProjectBuildingInsertProjectCommand,
|
|
||||||
assertProjectBuildingUpdateProjectCommand,
|
|
||||||
assertProjectFloorDeleteProjectCommand,
|
assertProjectFloorDeleteProjectCommand,
|
||||||
assertProjectFloorInsertProjectCommand,
|
assertProjectFloorInsertProjectCommand,
|
||||||
assertProjectFloorUpdateProjectCommand,
|
assertProjectFloorUpdateProjectCommand,
|
||||||
assertProjectRoomDeleteProjectCommand,
|
assertProjectRoomDeleteProjectCommand,
|
||||||
assertProjectRoomInsertProjectCommand,
|
assertProjectRoomInsertProjectCommand,
|
||||||
assertProjectRoomUpdateProjectCommand,
|
assertProjectRoomUpdateProjectCommand,
|
||||||
createProjectBuildingDeleteProjectCommand,
|
|
||||||
createProjectBuildingInsertProjectCommand,
|
|
||||||
createProjectBuildingUpdateProjectCommand,
|
|
||||||
createProjectFloorDeleteProjectCommand,
|
createProjectFloorDeleteProjectCommand,
|
||||||
createProjectFloorInsertProjectCommand,
|
createProjectFloorInsertProjectCommand,
|
||||||
createProjectFloorUpdateProjectCommand,
|
createProjectFloorUpdateProjectCommand,
|
||||||
createProjectRoomDeleteProjectCommand,
|
createProjectRoomDeleteProjectCommand,
|
||||||
createProjectRoomInsertProjectCommand,
|
createProjectRoomInsertProjectCommand,
|
||||||
createProjectRoomUpdateProjectCommand,
|
createProjectRoomUpdateProjectCommand,
|
||||||
projectBuildingDeleteCommandType,
|
|
||||||
projectBuildingInsertCommandType,
|
|
||||||
projectBuildingUpdateCommandType,
|
|
||||||
projectFloorDeleteCommandType,
|
projectFloorDeleteCommandType,
|
||||||
projectFloorInsertCommandType,
|
projectFloorInsertCommandType,
|
||||||
projectFloorUpdateCommandType,
|
projectFloorUpdateCommandType,
|
||||||
projectRoomDeleteCommandType,
|
projectRoomDeleteCommandType,
|
||||||
projectRoomInsertCommandType,
|
projectRoomInsertCommandType,
|
||||||
projectRoomUpdateCommandType,
|
projectRoomUpdateCommandType,
|
||||||
upgradeProjectFloorCommand,
|
|
||||||
type ProjectBuildingSnapshot,
|
|
||||||
type ProjectFloorSnapshot,
|
type ProjectFloorSnapshot,
|
||||||
type ProjectLocationStructureProjectCommand,
|
type ProjectLocationStructureProjectCommand,
|
||||||
type ProjectRoomSnapshot,
|
type ProjectRoomSnapshot,
|
||||||
|
|
@ -38,7 +27,6 @@ import type {
|
||||||
ProjectLocationStructureProjectCommandStore,
|
ProjectLocationStructureProjectCommandStore,
|
||||||
} from "../../domain/ports/project-location-structure-project-command.store.js";
|
} from "../../domain/ports/project-location-structure-project-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { buildings } from "../schema/buildings.js";
|
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
|
@ -52,14 +40,11 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
execute(input: ExecuteProjectLocationStructureCommandInput) {
|
execute(input: ExecuteProjectLocationStructureCommandInput) {
|
||||||
// Forward and inverse are persisted with one schema version, so a
|
|
||||||
// version 1 floor command from older history is upgraded first.
|
|
||||||
const command = toCurrentCommandVersion(input.command);
|
|
||||||
return executeProjectCommandTransaction(
|
return executeProjectCommandTransaction(
|
||||||
this.database,
|
this.database,
|
||||||
{ ...input, command },
|
input,
|
||||||
(tx) =>
|
(tx) =>
|
||||||
this.applyCommand(tx, input.projectId, command)
|
this.applyCommand(tx, input.projectId, input.command)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,43 +54,23 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
command: ProjectLocationStructureProjectCommand
|
command: ProjectLocationStructureProjectCommand
|
||||||
): ProjectLocationStructureProjectCommand {
|
): ProjectLocationStructureProjectCommand {
|
||||||
switch (command.type) {
|
switch (command.type) {
|
||||||
case projectBuildingInsertCommandType:
|
case projectFloorInsertCommandType:
|
||||||
assertProjectBuildingInsertProjectCommand(command);
|
|
||||||
return this.insertBuilding(database, projectId, command.payload.building);
|
|
||||||
case projectBuildingDeleteCommandType:
|
|
||||||
assertProjectBuildingDeleteProjectCommand(command);
|
|
||||||
return this.deleteBuilding(database, projectId, command.payload.building);
|
|
||||||
case projectBuildingUpdateCommandType:
|
|
||||||
assertProjectBuildingUpdateProjectCommand(command);
|
|
||||||
return this.updateBuilding(
|
|
||||||
database,
|
|
||||||
projectId,
|
|
||||||
command.payload.expected,
|
|
||||||
command.payload.target
|
|
||||||
);
|
|
||||||
case projectFloorInsertCommandType: {
|
|
||||||
assertProjectFloorInsertProjectCommand(command);
|
assertProjectFloorInsertProjectCommand(command);
|
||||||
const current = upgradeProjectFloorCommand(command);
|
|
||||||
return this.insertFloor(
|
return this.insertFloor(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
current.payload.floor
|
command.payload.floor
|
||||||
);
|
);
|
||||||
}
|
case projectFloorDeleteCommandType:
|
||||||
case projectFloorDeleteCommandType: {
|
|
||||||
assertProjectFloorDeleteProjectCommand(command);
|
assertProjectFloorDeleteProjectCommand(command);
|
||||||
const current = upgradeProjectFloorCommand(command);
|
|
||||||
return this.deleteFloor(
|
return this.deleteFloor(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
current.payload.floor
|
command.payload.floor
|
||||||
);
|
);
|
||||||
}
|
case projectFloorUpdateCommandType:
|
||||||
case projectFloorUpdateCommandType: {
|
|
||||||
assertProjectFloorUpdateProjectCommand(command);
|
assertProjectFloorUpdateProjectCommand(command);
|
||||||
const current = upgradeProjectFloorCommand(command);
|
return this.updateFloor(database, projectId, command.payload.expected, command.payload.target);
|
||||||
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
|
||||||
}
|
|
||||||
case projectRoomInsertCommandType:
|
case projectRoomInsertCommandType:
|
||||||
assertProjectRoomInsertProjectCommand(command);
|
assertProjectRoomInsertProjectCommand(command);
|
||||||
return this.insertRoom(
|
return this.insertRoom(
|
||||||
|
|
@ -135,7 +100,6 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
if (floor.projectId !== projectId) {
|
if (floor.projectId !== projectId) {
|
||||||
throw new Error("Project-floor snapshot does not belong to project.");
|
throw new Error("Project-floor snapshot does not belong to project.");
|
||||||
}
|
}
|
||||||
this.assertBuildingInProject(database, projectId, floor.buildingId);
|
|
||||||
const existing = database
|
const existing = database
|
||||||
.select({ id: floors.id })
|
.select({ id: floors.id })
|
||||||
.from(floors)
|
.from(floors)
|
||||||
|
|
@ -203,104 +167,6 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
return createProjectFloorInsertProjectCommand(expected);
|
return createProjectFloorInsertProjectCommand(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
private insertBuilding(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
building: ProjectBuildingSnapshot
|
|
||||||
) {
|
|
||||||
this.assertProjectExists(database, projectId);
|
|
||||||
if (building.projectId !== projectId) {
|
|
||||||
throw new Error("Project-building snapshot does not belong to project.");
|
|
||||||
}
|
|
||||||
const existing = database
|
|
||||||
.select({ id: buildings.id })
|
|
||||||
.from(buildings)
|
|
||||||
.where(eq(buildings.id, building.id))
|
|
||||||
.get();
|
|
||||||
if (existing) {
|
|
||||||
throw new Error("Project-building id already exists.");
|
|
||||||
}
|
|
||||||
database.insert(buildings).values(building).run();
|
|
||||||
return createProjectBuildingDeleteProjectCommand(building);
|
|
||||||
}
|
|
||||||
|
|
||||||
private deleteBuilding(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
expected: ProjectBuildingSnapshot
|
|
||||||
) {
|
|
||||||
const persisted = database
|
|
||||||
.select()
|
|
||||||
.from(buildings)
|
|
||||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
|
||||||
.get();
|
|
||||||
if (!persisted || !sameRecord(expected, persisted)) {
|
|
||||||
throw new Error("Project building changed before deletion.");
|
|
||||||
}
|
|
||||||
const referencedFloor = database
|
|
||||||
.select({ id: floors.id })
|
|
||||||
.from(floors)
|
|
||||||
.where(eq(floors.buildingId, expected.id))
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
if (referencedFloor) {
|
|
||||||
throw new Error(
|
|
||||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const deleted = database
|
|
||||||
.delete(buildings)
|
|
||||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
|
||||||
.run();
|
|
||||||
if (deleted.changes !== 1) {
|
|
||||||
throw new Error("Project building could not be deleted.");
|
|
||||||
}
|
|
||||||
return createProjectBuildingInsertProjectCommand(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateBuilding(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
expected: ProjectBuildingSnapshot,
|
|
||||||
target: ProjectBuildingSnapshot
|
|
||||||
) {
|
|
||||||
const persisted = database
|
|
||||||
.select()
|
|
||||||
.from(buildings)
|
|
||||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
|
||||||
.get();
|
|
||||||
if (!persisted || !sameRecord(expected, persisted)) {
|
|
||||||
throw new Error("Project building changed before update.");
|
|
||||||
}
|
|
||||||
const updated = database
|
|
||||||
.update(buildings)
|
|
||||||
.set({ name: target.name })
|
|
||||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
|
||||||
.run();
|
|
||||||
if (updated.changes !== 1) {
|
|
||||||
throw new Error("Das Gebäude konnte nicht aktualisiert werden.");
|
|
||||||
}
|
|
||||||
return createProjectBuildingUpdateProjectCommand(target, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertBuildingInProject(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
buildingId: string | null
|
|
||||||
) {
|
|
||||||
if (buildingId === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const building = database
|
|
||||||
.select({ id: buildings.id })
|
|
||||||
.from(buildings)
|
|
||||||
.where(and(eq(buildings.id, buildingId), eq(buildings.projectId, projectId)))
|
|
||||||
.get();
|
|
||||||
if (!building) {
|
|
||||||
throw new Error("Project floor references a foreign building.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private insertRoom(
|
private insertRoom(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -351,10 +217,9 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
if (!persisted || !sameRecord(expected, persisted)) {
|
if (!persisted || !sameRecord(expected, persisted)) {
|
||||||
throw new Error("Project floor changed before update.");
|
throw new Error("Project floor changed before update.");
|
||||||
}
|
}
|
||||||
this.assertBuildingInProject(database, projectId, target.buildingId);
|
|
||||||
const updated = database
|
const updated = database
|
||||||
.update(floors)
|
.update(floors)
|
||||||
.set({ name: target.name, buildingId: target.buildingId })
|
.set({ name: target.name })
|
||||||
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
|
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
|
|
@ -461,24 +326,6 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toCurrentCommandVersion(
|
|
||||||
command: ProjectLocationStructureProjectCommand
|
|
||||||
): ProjectLocationStructureProjectCommand {
|
|
||||||
switch (command.type) {
|
|
||||||
case projectFloorInsertCommandType:
|
|
||||||
assertProjectFloorInsertProjectCommand(command);
|
|
||||||
return upgradeProjectFloorCommand(command);
|
|
||||||
case projectFloorDeleteCommandType:
|
|
||||||
assertProjectFloorDeleteProjectCommand(command);
|
|
||||||
return upgradeProjectFloorCommand(command);
|
|
||||||
case projectFloorUpdateCommandType:
|
|
||||||
assertProjectFloorUpdateProjectCommand(command);
|
|
||||||
return upgradeProjectFloorCommand(command);
|
|
||||||
default:
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameRecord(
|
function sameRecord(
|
||||||
expected: object,
|
expected: object,
|
||||||
actual: object
|
actual: object
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
import { and, eq, isNotNull } from "drizzle-orm";
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
assertProjectSettingsUpdateProjectCommand,
|
assertProjectSettingsUpdateProjectCommand,
|
||||||
baselineProjectSettingsUpdateCommandSchemaVersion,
|
|
||||||
createProjectSettingsUpdateProjectCommand,
|
createProjectSettingsUpdateProjectCommand,
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
|
|
||||||
type ProjectSettingsValues,
|
type ProjectSettingsValues,
|
||||||
} from "../../domain/models/project-settings-project-command.model.js";
|
} from "../../domain/models/project-settings-project-command.model.js";
|
||||||
import { projectCoverFields } from "../../shared/constants/project-cover.js";
|
|
||||||
import type {
|
import type {
|
||||||
ExecuteProjectSettingsUpdateCommandInput,
|
ExecuteProjectSettingsUpdateCommandInput,
|
||||||
ProjectSettingsProjectCommandStore,
|
ProjectSettingsProjectCommandStore,
|
||||||
|
|
@ -43,27 +40,13 @@ export class ProjectSettingsProjectCommandRepository
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new Error("Project not found.");
|
throw new Error("Project not found.");
|
||||||
}
|
}
|
||||||
const currentCoverValues = {
|
|
||||||
siteAddress: current.siteAddress,
|
|
||||||
workStage: current.workStage,
|
|
||||||
projectLead: current.projectLead,
|
|
||||||
projectEditor: current.projectEditor,
|
|
||||||
};
|
|
||||||
// Older command versions do not carry every value; missing values keep
|
|
||||||
// the current project state and are therefore also absent from the
|
|
||||||
// inverse of the same version.
|
|
||||||
const command = input.command;
|
|
||||||
const target: ProjectSettingsValues =
|
const target: ProjectSettingsValues =
|
||||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
input.command.schemaVersion === 1
|
||||||
? {
|
? {
|
||||||
...command.payload,
|
...input.command.payload,
|
||||||
isPublicBuilding: current.isPublicBuilding,
|
isPublicBuilding: current.isPublicBuilding,
|
||||||
...currentCoverValues,
|
|
||||||
}
|
}
|
||||||
: command.schemaVersion ===
|
: input.command.payload;
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? { ...command.payload, ...currentCoverValues }
|
|
||||||
: command.payload;
|
|
||||||
if (projectSettingsEqual(current, target)) {
|
if (projectSettingsEqual(current, target)) {
|
||||||
throw new Error("Project settings did not change.");
|
throw new Error("Project settings did not change.");
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +62,11 @@ export class ProjectSettingsProjectCommandRepository
|
||||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||||
enabledDistributionBoardSupplyTypes:
|
enabledDistributionBoardSupplyTypes:
|
||||||
current.enabledDistributionBoardSupplyTypes,
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
...currentCoverValues,
|
|
||||||
};
|
};
|
||||||
const inverse =
|
const inverse =
|
||||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
input.command.schemaVersion === 1
|
||||||
? createBaselineProjectSettingsUpdateCommand(inverseValues)
|
? createBaselineProjectSettingsUpdateCommand(inverseValues)
|
||||||
: command.schemaVersion ===
|
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
|
|
||||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
|
||||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||||
const updated = tx
|
const updated = tx
|
||||||
.update(projects)
|
.update(projects)
|
||||||
|
|
@ -103,32 +82,11 @@ export class ProjectSettingsProjectCommandRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWithoutCoverProjectSettingsUpdateCommand(
|
|
||||||
values: ProjectSettingsValues
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
|
|
||||||
type: "project.update-settings" as const,
|
|
||||||
payload: {
|
|
||||||
name: values.name,
|
|
||||||
internalProjectNumber: values.internalProjectNumber,
|
|
||||||
externalProjectNumber: values.externalProjectNumber,
|
|
||||||
buildingOwner: values.buildingOwner,
|
|
||||||
description: values.description,
|
|
||||||
isPublicBuilding: values.isPublicBuilding,
|
|
||||||
singlePhaseVoltageV: values.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: values.threePhaseVoltageV,
|
|
||||||
enabledDistributionBoardSupplyTypes:
|
|
||||||
values.enabledDistributionBoardSupplyTypes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBaselineProjectSettingsUpdateCommand(
|
function createBaselineProjectSettingsUpdateCommand(
|
||||||
values: ProjectSettingsValues
|
values: ProjectSettingsValues
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
|
schemaVersion: 1 as const,
|
||||||
type: "project.update-settings" as const,
|
type: "project.update-settings" as const,
|
||||||
payload: {
|
payload: {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
|
@ -156,9 +114,8 @@ function projectSettingsEqual(
|
||||||
current.description === target.description &&
|
current.description === target.description &&
|
||||||
current.isPublicBuilding === target.isPublicBuilding &&
|
current.isPublicBuilding === target.isPublicBuilding &&
|
||||||
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
|
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
|
||||||
current.threePhaseVoltageV === target.threePhaseVoltageV &&
|
current.threePhaseVoltageV === target.threePhaseVoltageV
|
||||||
projectCoverFields.every((field) => current[field] === target[field]) &&
|
&& sameSupplyTypes(
|
||||||
sameSupplyTypes(
|
|
||||||
current.enabledDistributionBoardSupplyTypes,
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
target.enabledDistributionBoardSupplyTypes
|
target.enabledDistributionBoardSupplyTypes
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn
|
||||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
deserializeProjectStateSnapshot,
|
deserializeProjectStateSnapshot,
|
||||||
isSupportedProjectStateSnapshotSchemaVersion,
|
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
} from "../../domain/models/project-state-snapshot.model.js";
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -139,7 +138,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||||
if (actualChecksum !== stored.payloadSha256) {
|
if (actualChecksum !== stored.payloadSha256) {
|
||||||
throw new Error("Project snapshot checksum verification failed.");
|
throw new Error("Project snapshot checksum verification failed.");
|
||||||
}
|
}
|
||||||
if (!isSupportedProjectStateSnapshotSchemaVersion(stored.schemaVersion)) {
|
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
throw new Error("Project snapshot schema version is not supported.");
|
throw new Error("Project snapshot schema version is not supported.");
|
||||||
}
|
}
|
||||||
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
|
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
|
||||||
import {
|
import {
|
||||||
assertProjectStateRestoreCommand,
|
assertProjectStateRestoreCommand,
|
||||||
createProjectStateRestoreCommand,
|
createProjectStateRestoreCommand,
|
||||||
createProjectTransferRestoreCommand,
|
|
||||||
projectStateRestoreCommandSchemaVersion,
|
|
||||||
} from "../../domain/models/project-state-restore-command.model.js";
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
parseProjectStateSnapshot,
|
parseProjectStateSnapshot,
|
||||||
|
|
@ -15,8 +13,6 @@ import type {
|
||||||
ProjectStateRestoreCommandStore,
|
ProjectStateRestoreCommandStore,
|
||||||
} from "../../domain/ports/project-state-restore-command.store.js";
|
} from "../../domain/ports/project-state-restore-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
|
||||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||||
|
|
@ -25,7 +21,6 @@ import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { buildings } from "../schema/buildings.js";
|
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
|
|
@ -36,11 +31,6 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
import { estimateBalances } from "../schema/estimate-balances.js";
|
|
||||||
import {
|
|
||||||
insertEstimateBalance,
|
|
||||||
readEstimateBalances,
|
|
||||||
} from "./estimate-balance.persistence.js";
|
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import {
|
import {
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
|
|
@ -75,39 +65,10 @@ export class ProjectStateRestoreCommandRepository
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new Error("Project not found.");
|
throw new Error("Project not found.");
|
||||||
}
|
}
|
||||||
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
|
|
||||||
const transferCommand =
|
|
||||||
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
|
|
||||||
? input.command
|
|
||||||
: null;
|
|
||||||
if (
|
if (
|
||||||
current.payloadSha256 !==
|
current.payloadSha256 !==
|
||||||
input.command.payload.expectedStateSha256 &&
|
input.command.payload.expectedStateSha256 &&
|
||||||
!matchesHistoryRestoreTarget(
|
!matchesHistoryRestoreTarget(tx, input, current.state)
|
||||||
tx,
|
|
||||||
input,
|
|
||||||
current.state,
|
|
||||||
currentEstimateBalances
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ProjectStateConflictError(
|
|
||||||
input.projectId,
|
|
||||||
input.command.payload.expectedStateSha256,
|
|
||||||
current.payloadSha256
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
transferCommand &&
|
|
||||||
canonicalEstimateBalances(currentEstimateBalances) !==
|
|
||||||
canonicalEstimateBalances(
|
|
||||||
transferCommand.payload.expectedEstimateBalances
|
|
||||||
) &&
|
|
||||||
!matchesHistoryRestoreTarget(
|
|
||||||
tx,
|
|
||||||
input,
|
|
||||||
current.state,
|
|
||||||
currentEstimateBalances
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
throw new ProjectStateConflictError(
|
throw new ProjectStateConflictError(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
|
|
@ -120,28 +81,14 @@ export class ProjectStateRestoreCommandRepository
|
||||||
input.command.payload.targetState
|
input.command.payload.targetState
|
||||||
);
|
);
|
||||||
replaceProjectState(tx, targetState);
|
replaceProjectState(tx, targetState);
|
||||||
if (transferCommand) {
|
|
||||||
replaceEstimateBalances(
|
|
||||||
tx,
|
|
||||||
input.projectId,
|
|
||||||
transferCommand.payload.targetEstimateBalances
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||||
if (!persistedTarget) {
|
if (!persistedTarget) {
|
||||||
throw new Error("Restored project could not be loaded.");
|
throw new Error("Restored project could not be loaded.");
|
||||||
}
|
}
|
||||||
const inverse = transferCommand
|
const inverse = createProjectStateRestoreCommand(
|
||||||
? createProjectTransferRestoreCommand(
|
persistedTarget.payloadSha256,
|
||||||
persistedTarget.payloadSha256,
|
current.state
|
||||||
current.state,
|
);
|
||||||
transferCommand.payload.targetEstimateBalances,
|
|
||||||
currentEstimateBalances
|
|
||||||
)
|
|
||||||
: createProjectStateRestoreCommand(
|
|
||||||
persistedTarget.payloadSha256,
|
|
||||||
current.state
|
|
||||||
);
|
|
||||||
|
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
@ -150,8 +97,7 @@ export class ProjectStateRestoreCommandRepository
|
||||||
function matchesHistoryRestoreTarget(
|
function matchesHistoryRestoreTarget(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
input: ExecuteProjectStateRestoreCommandInput,
|
input: ExecuteProjectStateRestoreCommandInput,
|
||||||
currentState: ProjectStateSnapshot,
|
currentState: ProjectStateSnapshot
|
||||||
currentEstimateBalances: EstimateBalanceSnapshot[]
|
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
(input.source !== "undo" && input.source !== "redo") ||
|
(input.source !== "undo" && input.source !== "redo") ||
|
||||||
|
|
@ -175,45 +121,25 @@ function matchesHistoryRestoreTarget(
|
||||||
input.source === "undo"
|
input.source === "undo"
|
||||||
? changeSet.forwardPayloadJson
|
? changeSet.forwardPayloadJson
|
||||||
: changeSet.inversePayloadJson;
|
: changeSet.inversePayloadJson;
|
||||||
const command = JSON.parse(serialized) as unknown;
|
const command = JSON.parse(serialized) as {
|
||||||
assertProjectStateRestoreCommand(command);
|
type?: unknown;
|
||||||
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
|
payload?: { targetState?: unknown };
|
||||||
const stateMatches =
|
};
|
||||||
canonicalProjectState(expectedState) ===
|
if (command.type !== "project.restore-state") {
|
||||||
canonicalProjectState(currentState);
|
|
||||||
if (!stateMatches) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
|
const expectedState = parseProjectStateSnapshot(
|
||||||
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
|
command.payload?.targetState
|
||||||
canonicalEstimateBalances(currentEstimateBalances);
|
);
|
||||||
|
return (
|
||||||
|
canonicalProjectState(expectedState) ===
|
||||||
|
canonicalProjectState(currentState)
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
|
|
||||||
return JSON.stringify(
|
|
||||||
[...balances]
|
|
||||||
.sort((left, right) => left.id.localeCompare(right.id))
|
|
||||||
.map((balance) => canonicalEstimateBalance(balance))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceEstimateBalances(
|
|
||||||
database: AppDatabase,
|
|
||||||
projectId: string,
|
|
||||||
balances: EstimateBalanceSnapshot[]
|
|
||||||
) {
|
|
||||||
database
|
|
||||||
.delete(estimateBalances)
|
|
||||||
.where(eq(estimateBalances.projectId, projectId))
|
|
||||||
.run();
|
|
||||||
for (const balance of balances) {
|
|
||||||
insertEstimateBalance(database, balance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalProjectState(state: ProjectStateSnapshot) {
|
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
...state,
|
...state,
|
||||||
|
|
@ -238,7 +164,6 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
projectDevices: byId(state.projectDevices),
|
projectDevices: byId(state.projectDevices),
|
||||||
buildings: byId(state.buildings),
|
|
||||||
floors: byId(state.floors),
|
floors: byId(state.floors),
|
||||||
rooms: byId(state.rooms),
|
rooms: byId(state.rooms),
|
||||||
externalCsvConfiguration: state.externalCsvConfiguration,
|
externalCsvConfiguration: state.externalCsvConfiguration,
|
||||||
|
|
@ -294,10 +219,6 @@ function replaceProjectState(
|
||||||
.delete(floors)
|
.delete(floors)
|
||||||
.where(eq(floors.projectId, state.project.id))
|
.where(eq(floors.projectId, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
database
|
|
||||||
.delete(buildings)
|
|
||||||
.where(eq(buildings.projectId, state.project.id))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
database
|
database
|
||||||
.update(projects)
|
.update(projects)
|
||||||
|
|
@ -312,15 +233,10 @@ function replaceProjectState(
|
||||||
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||||
enabledDistributionBoardSupplyTypes:
|
enabledDistributionBoardSupplyTypes:
|
||||||
state.project.enabledDistributionBoardSupplyTypes,
|
state.project.enabledDistributionBoardSupplyTypes,
|
||||||
siteAddress: state.project.siteAddress,
|
|
||||||
workStage: state.project.workStage,
|
|
||||||
projectLead: state.project.projectLead,
|
|
||||||
projectEditor: state.project.projectEditor,
|
|
||||||
})
|
})
|
||||||
.where(eq(projects.id, state.project.id))
|
.where(eq(projects.id, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
insertMany(database, buildings, state.buildings);
|
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
if (state.externalCsvConfiguration !== null) {
|
if (state.externalCsvConfiguration !== null) {
|
||||||
database
|
database
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { buildings } from "../schema/buildings.js";
|
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
@ -47,10 +46,6 @@ export function readProjectStateSnapshot(
|
||||||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
enabledDistributionBoardSupplyTypes:
|
enabledDistributionBoardSupplyTypes:
|
||||||
projects.enabledDistributionBoardSupplyTypes,
|
projects.enabledDistributionBoardSupplyTypes,
|
||||||
siteAddress: projects.siteAddress,
|
|
||||||
workStage: projects.workStage,
|
|
||||||
projectLead: projects.projectLead,
|
|
||||||
projectEditor: projects.projectEditor,
|
|
||||||
currentRevision: projects.currentRevision,
|
currentRevision: projects.currentRevision,
|
||||||
})
|
})
|
||||||
.from(projects)
|
.from(projects)
|
||||||
|
|
@ -220,12 +215,6 @@ export function readProjectStateSnapshot(
|
||||||
.where(eq(projectDevices.projectId, projectId))
|
.where(eq(projectDevices.projectId, projectId))
|
||||||
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
|
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
|
||||||
.all();
|
.all();
|
||||||
const buildingRows = database
|
|
||||||
.select()
|
|
||||||
.from(buildings)
|
|
||||||
.where(eq(buildings.projectId, projectId))
|
|
||||||
.orderBy(asc(buildings.sortOrder), asc(buildings.id))
|
|
||||||
.all();
|
|
||||||
const floorRows = database
|
const floorRows = database
|
||||||
.select()
|
.select()
|
||||||
.from(floors)
|
.from(floors)
|
||||||
|
|
@ -260,10 +249,6 @@ export function readProjectStateSnapshot(
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
enabledDistributionBoardSupplyTypes:
|
enabledDistributionBoardSupplyTypes:
|
||||||
project.enabledDistributionBoardSupplyTypes,
|
project.enabledDistributionBoardSupplyTypes,
|
||||||
siteAddress: project.siteAddress,
|
|
||||||
workStage: project.workStage,
|
|
||||||
projectLead: project.projectLead,
|
|
||||||
projectEditor: project.projectEditor,
|
|
||||||
},
|
},
|
||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
externalCsvConfiguration,
|
externalCsvConfiguration,
|
||||||
|
|
@ -280,7 +265,6 @@ export function readProjectStateSnapshot(
|
||||||
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
||||||
})),
|
})),
|
||||||
projectDevices: projectDeviceRows,
|
projectDevices: projectDeviceRows,
|
||||||
buildings: buildingRows,
|
|
||||||
floors: floorRows,
|
floors: floorRows,
|
||||||
rooms: roomRows,
|
rooms: roomRows,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@ import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createProjectTransferEnvelope,
|
createProjectTransferEnvelope,
|
||||||
parseProjectTransferEnvelope,
|
parseProjectTransferEnvelope,
|
||||||
remapEstimateBalances,
|
|
||||||
remapProjectState,
|
remapProjectState,
|
||||||
serializeProjectTransferPayload,
|
|
||||||
} from "../../domain/models/project-transfer.model.js";
|
} from "../../domain/models/project-transfer.model.js";
|
||||||
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
|
@ -18,7 +16,6 @@ import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { buildings } from "../schema/buildings.js";
|
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
@ -28,10 +25,6 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
import {
|
|
||||||
insertEstimateBalance,
|
|
||||||
readEstimateBalances,
|
|
||||||
} from "./estimate-balance.persistence.js";
|
|
||||||
import {
|
import {
|
||||||
hashProjectStatePayload,
|
hashProjectStatePayload,
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
|
|
@ -46,15 +39,10 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const estimateBalances = readEstimateBalances(this.database, projectId);
|
|
||||||
const payloadSha256 = hashProjectStatePayload(
|
|
||||||
serializeProjectTransferPayload(snapshot.state, estimateBalances)
|
|
||||||
);
|
|
||||||
return createProjectTransferEnvelope(
|
return createProjectTransferEnvelope(
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
payloadSha256,
|
snapshot.payloadSha256,
|
||||||
snapshot.state,
|
snapshot.state
|
||||||
estimateBalances
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,21 +60,10 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
projectId,
|
projectId,
|
||||||
() => crypto.randomUUID()
|
() => crypto.randomUUID()
|
||||||
);
|
);
|
||||||
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
|
|
||||||
const targetEstimateBalances =
|
|
||||||
transfer.projectState.project.id === projectId
|
|
||||||
? transfer.estimateBalances
|
|
||||||
: remapEstimateBalances(
|
|
||||||
transfer.estimateBalances,
|
|
||||||
projectId,
|
|
||||||
() => crypto.randomUUID()
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
command: createProjectTransferRestoreCommand(
|
command: createProjectStateRestoreCommand(
|
||||||
current.payloadSha256,
|
current.payloadSha256,
|
||||||
targetState,
|
targetState
|
||||||
currentEstimateBalances,
|
|
||||||
targetEstimateBalances
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -100,39 +77,20 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
() => crypto.randomUUID(),
|
() => crypto.randomUUID(),
|
||||||
`${transfer.projectState.project.name} (Kopie)`
|
`${transfer.projectState.project.name} (Kopie)`
|
||||||
);
|
);
|
||||||
const estimateBalances = remapEstimateBalances(
|
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||||
transfer.estimateBalances,
|
|
||||||
projectId,
|
|
||||||
() => crypto.randomUUID()
|
|
||||||
);
|
|
||||||
this.database.transaction((tx) => {
|
|
||||||
insertProjectState(tx, state);
|
|
||||||
for (const balance of estimateBalances) {
|
|
||||||
insertEstimateBalance(tx, balance);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { projectId, name: state.project.name };
|
return { projectId, name: state.project.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseVerified(value: unknown) {
|
private parseVerified(value: unknown) {
|
||||||
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
|
const rawPayloadSha256 =
|
||||||
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
|
isPlainObject(value) &&
|
||||||
? hashProjectStatePayload(
|
isPlainObject(value.projectState)
|
||||||
isLegacy
|
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
||||||
? JSON.stringify(value.projectState)
|
: null;
|
||||||
: JSON.stringify({
|
|
||||||
projectState: value.projectState,
|
|
||||||
estimateBalances: value.estimateBalances,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const transfer = parseProjectTransferEnvelope(value);
|
const transfer = parseProjectTransferEnvelope(value);
|
||||||
const payloadJson = isLegacy
|
const payloadJson = serializeProjectStateSnapshot(
|
||||||
? serializeProjectStateSnapshot(transfer.projectState)
|
transfer.projectState
|
||||||
: serializeProjectTransferPayload(
|
);
|
||||||
transfer.projectState,
|
|
||||||
transfer.estimateBalances
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||||
rawPayloadSha256 !== transfer.payloadSha256
|
rawPayloadSha256 !== transfer.payloadSha256
|
||||||
|
|
@ -169,7 +127,6 @@ function insertProjectState(
|
||||||
.values(state.externalCsvConfiguration)
|
.values(state.externalCsvConfiguration)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
insertMany(database, buildings, state.buildings);
|
|
||||||
insertMany(database, floors, state.floors);
|
insertMany(database, floors, state.floors);
|
||||||
insertMany(database, rooms, state.rooms);
|
insertMany(database, rooms, state.rooms);
|
||||||
insertMany(database, projectDevices, state.projectDevices);
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,6 @@ export class ProjectRepository {
|
||||||
enabledDistributionBoardSupplyTypes: [
|
enabledDistributionBoardSupplyTypes: [
|
||||||
...defaultDistributionBoardSupplyTypes,
|
...defaultDistributionBoardSupplyTypes,
|
||||||
],
|
],
|
||||||
siteAddress: null,
|
|
||||||
workStage: null,
|
|
||||||
projectLead: null,
|
|
||||||
projectEditor: null,
|
|
||||||
currentRevision: 0,
|
currentRevision: 0,
|
||||||
};
|
};
|
||||||
await db.insert(projects).values(project);
|
await db.insert(projects).values(project);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
import { projects } from "./projects.js";
|
|
||||||
|
|
||||||
export const buildings = sqliteTable("buildings", {
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
});
|
|
||||||
|
|
@ -16,9 +16,5 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
||||||
simultaneityFactor: real("simultaneity_factor")
|
simultaneityFactor: real("simultaneity_factor")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(1),
|
.default(1),
|
||||||
boardNumber: text("board_number"),
|
|
||||||
location: text("location"),
|
|
||||||
fedFrom: text("fed_from"),
|
|
||||||
remark: text("remark"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
// Office-wide branding for printed documents (currently only the logo shown
|
|
||||||
// on the cover page and plan title blocks). Global application data, not part
|
|
||||||
// of any project: it is deliberately outside project revisions, logical
|
|
||||||
// project snapshots and project transfer.
|
|
||||||
export const documentBrandingAssets = sqliteTable("document_branding_assets", {
|
|
||||||
key: text("key").primaryKey(),
|
|
||||||
mimeType: text("mime_type").notNull(),
|
|
||||||
fileName: text("file_name"),
|
|
||||||
data: blob("data", { mode: "buffer" }).notNull(),
|
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type DocumentBrandingAsset = typeof documentBrandingAssets.$inferSelect;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
// Office-wide text settings for printed documents (e.g. office name and
|
|
||||||
// address on the cover page). Global application data like
|
|
||||||
// document_branding_assets: outside project revisions, snapshots and transfer.
|
|
||||||
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
|
|
||||||
key: text("key").primaryKey(),
|
|
||||||
value: text("value").notNull(),
|
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
import { projects } from "./projects.js";
|
|
||||||
|
|
||||||
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
|
||||||
// circuit structure. See docs/estimate-balance.md.
|
|
||||||
export const estimateBalances = sqliteTable(
|
|
||||||
"estimate_balances",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
workStage: text("work_stage"),
|
|
||||||
dateIso: text("date_iso"),
|
|
||||||
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
|
||||||
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
},
|
|
||||||
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const estimateGroups = sqliteTable(
|
|
||||||
"estimate_groups",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
balanceId: text("balance_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
|
||||||
costGroup: text("cost_group").notNull().default(""),
|
|
||||||
title: text("title").notNull(),
|
|
||||||
editor: text("editor"),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
},
|
|
||||||
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const estimateRows = sqliteTable(
|
|
||||||
"estimate_rows",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
groupId: text("group_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
supply: text("supply"),
|
|
||||||
name: text("name").notNull().default(""),
|
|
||||||
quantity: real("quantity").notNull().default(0),
|
|
||||||
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
|
||||||
connection: text("connection", { enum: ["fixed", "socket"] }),
|
|
||||||
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
|
||||||
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
|
||||||
cosPhi: real("cos_phi").notNull().default(1),
|
|
||||||
safetySupply: integer("safety_supply", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
remark: text("remark"),
|
|
||||||
},
|
|
||||||
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
|
||||||
);
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import { buildings } from "./buildings.js";
|
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
export const floors = sqliteTable("floors", {
|
export const floors = sqliteTable("floors", {
|
||||||
|
|
@ -9,9 +8,4 @@ export const floors = sqliteTable("floors", {
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
// Optional parent building. Floors created before buildings existed stay
|
|
||||||
// without one and are shown as "Ohne Gebäude".
|
|
||||||
buildingId: text("building_id").references(() => buildings.id, {
|
|
||||||
onDelete: "set null",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,5 @@ export const projects = sqliteTable("projects", {
|
||||||
.$type<DistributionBoardSupplyType[]>()
|
.$type<DistributionBoardSupplyType[]>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
||||||
siteAddress: text("site_address"),
|
|
||||||
workStage: text("work_stage"),
|
|
||||||
projectLead: text("project_lead"),
|
|
||||||
projectEditor: text("project_editor"),
|
|
||||||
currentRevision: integer("current_revision").notNull().default(0),
|
currentRevision: integer("current_revision").notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
import {
|
import {
|
||||||
distributionBoardInfoFieldMaxLengths,
|
|
||||||
distributionBoardInfoFields,
|
|
||||||
distributionBoardSupplyTypes,
|
distributionBoardSupplyTypes,
|
||||||
type DistributionBoardInfoField,
|
|
||||||
type DistributionBoardSupplyType,
|
type DistributionBoardSupplyType,
|
||||||
} from "../../shared/constants/distribution-board.js";
|
} from "../../shared/constants/distribution-board.js";
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
export const distributionBoardUpdateCommandType =
|
export const distributionBoardUpdateCommandType =
|
||||||
"distribution-board.update" as const;
|
"distribution-board.update" as const;
|
||||||
// The info-card fields were added additively to schema version 1: every
|
|
||||||
// previously stored command remains valid, and the inverse is still derived
|
|
||||||
// from the persisted row for exactly the changed fields.
|
|
||||||
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
|
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
export interface DistributionBoardUpdateValues
|
export interface DistributionBoardUpdateValues {
|
||||||
extends Record<DistributionBoardInfoField, string | null> {
|
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
supplyType: DistributionBoardSupplyType | null;
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
|
|
@ -89,20 +82,16 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
if (
|
if (
|
||||||
!isPlainObject(change) ||
|
!isPlainObject(change) ||
|
||||||
typeof change.field !== "string" ||
|
|
||||||
(change.field !== "floorId" &&
|
(change.field !== "floorId" &&
|
||||||
change.field !== "supplyType" &&
|
change.field !== "supplyType" &&
|
||||||
change.field !== "simultaneityFactor" &&
|
change.field !== "simultaneityFactor") ||
|
||||||
!isInfoField(change.field)) ||
|
|
||||||
seen.has(change.field)
|
seen.has(change.field)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Distribution-board update contains an invalid or duplicate field."
|
"Distribution-board update contains an invalid or duplicate field."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (isInfoField(change.field)) {
|
if (change.field === "floorId") {
|
||||||
assertInfoFieldValue(change.field, change.value);
|
|
||||||
} else if (change.field === "floorId") {
|
|
||||||
if (
|
if (
|
||||||
change.value !== null &&
|
change.value !== null &&
|
||||||
(typeof change.value !== "string" || !change.value.trim())
|
(typeof change.value !== "string" || !change.value.trim())
|
||||||
|
|
@ -132,29 +121,6 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInfoField(field: string): field is DistributionBoardInfoField {
|
|
||||||
return (distributionBoardInfoFields as readonly string[]).includes(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertInfoFieldValue(
|
|
||||||
field: DistributionBoardInfoField,
|
|
||||||
value: unknown
|
|
||||||
) {
|
|
||||||
if (value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof value !== "string" ||
|
|
||||||
!value.trim() ||
|
|
||||||
value !== value.trim() ||
|
|
||||||
value.length > distributionBoardInfoFieldMaxLengths[field]
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`${field} must be a trimmed, non-empty string of at most ${distributionBoardInfoFieldMaxLengths[field]} characters or null.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ import {
|
||||||
type DistributionBoardComponentRole,
|
type DistributionBoardComponentRole,
|
||||||
} from "../../shared/constants/distribution-board-component.js";
|
} from "../../shared/constants/distribution-board-component.js";
|
||||||
import {
|
import {
|
||||||
distributionBoardInfoFields,
|
|
||||||
distributionBoardSupplyTypes,
|
distributionBoardSupplyTypes,
|
||||||
type DistributionBoardInfoField,
|
|
||||||
type DistributionBoardSupplyType,
|
type DistributionBoardSupplyType,
|
||||||
} from "../../shared/constants/distribution-board.js";
|
} from "../../shared/constants/distribution-board.js";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -74,7 +72,7 @@ export interface DistributionBoardSubtreeSnapshot {
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
supplyType: DistributionBoardSupplyType | null;
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
} & Record<DistributionBoardInfoField, string | null>;
|
};
|
||||||
circuitList: {
|
circuitList: {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -171,22 +169,6 @@ export function cloneDistributionBoardSubtree(
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Subtree commands stored before the board info fields existed carry no
|
|
||||||
* info values. They are equivalent to a board whose info fields are empty,
|
|
||||||
* so comparisons against a freshly captured subtree and re-inserts stay
|
|
||||||
* exact.
|
|
||||||
*/
|
|
||||||
export function normalizeDistributionBoardSubtreeSnapshot(
|
|
||||||
snapshot: DistributionBoardSubtreeSnapshot
|
|
||||||
): DistributionBoardSubtreeSnapshot {
|
|
||||||
const board = { ...snapshot.distributionBoard };
|
|
||||||
for (const field of distributionBoardInfoFields) {
|
|
||||||
board[field] = board[field] ?? null;
|
|
||||||
}
|
|
||||||
return { ...snapshot, distributionBoard: board };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertDistributionBoardSubtreeSnapshot(
|
export function assertDistributionBoardSubtreeSnapshot(
|
||||||
value: unknown
|
value: unknown
|
||||||
): asserts value is DistributionBoardSubtreeSnapshot {
|
): asserts value is DistributionBoardSubtreeSnapshot {
|
||||||
|
|
@ -222,11 +204,6 @@ export function assertDistributionBoardSubtreeSnapshot(
|
||||||
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
|
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
|
||||||
throw new Error("Distribution-board simultaneity factor is invalid.");
|
throw new Error("Distribution-board simultaneity factor is invalid.");
|
||||||
}
|
}
|
||||||
for (const field of distributionBoardInfoFields) {
|
|
||||||
if (board[field] !== undefined) {
|
|
||||||
assertNullableString(board[field], `distributionBoard.${field}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const field of [
|
for (const field of [
|
||||||
"id",
|
"id",
|
||||||
"projectId",
|
"projectId",
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,21 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
export const projectBuildingInsertCommandType = "project-building.insert" as const;
|
|
||||||
export const projectBuildingDeleteCommandType = "project-building.delete" as const;
|
|
||||||
export const projectBuildingUpdateCommandType = "project-building.update" as const;
|
|
||||||
export const projectFloorInsertCommandType = "project-floor.insert" as const;
|
export const projectFloorInsertCommandType = "project-floor.insert" as const;
|
||||||
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
|
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
|
||||||
export const projectFloorUpdateCommandType = "project-floor.update" as const;
|
export const projectFloorUpdateCommandType = "project-floor.update" as const;
|
||||||
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
||||||
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
||||||
export const projectRoomUpdateCommandType = "project-room.update" as const;
|
export const projectRoomUpdateCommandType = "project-room.update" as const;
|
||||||
/** Room commands, building commands and legacy floor commands. */
|
|
||||||
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||||
/**
|
|
||||||
* Floor commands carry `buildingId` since version 2. Version 1 floor
|
|
||||||
* commands stay executable from persisted history: they were recorded before
|
|
||||||
* buildings existed, so they are upgraded with `buildingId = null`.
|
|
||||||
*/
|
|
||||||
export const projectFloorCommandSchemaVersion = 2 as const;
|
|
||||||
const legacyProjectFloorCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ProjectBuildingSnapshot {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectFloorSnapshot {
|
export interface ProjectFloorSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
buildingId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LegacyProjectFloorSnapshot = Omit<ProjectFloorSnapshot, "buildingId">;
|
|
||||||
|
|
||||||
export interface ProjectRoomSnapshot {
|
export interface ProjectRoomSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -45,93 +24,39 @@ export interface ProjectRoomSnapshot {
|
||||||
roomName: string;
|
roomName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectBuildingStructureCommandPayload {
|
|
||||||
building: ProjectBuildingSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectFloorStructureCommandPayload {
|
interface ProjectFloorStructureCommandPayload {
|
||||||
floor: ProjectFloorSnapshot;
|
floor: ProjectFloorSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegacyProjectFloorStructureCommandPayload {
|
|
||||||
floor: LegacyProjectFloorSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectRoomStructureCommandPayload {
|
interface ProjectRoomStructureCommandPayload {
|
||||||
room: ProjectRoomSnapshot;
|
room: ProjectRoomSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectBuildingUpdateCommandPayload {
|
|
||||||
expected: ProjectBuildingSnapshot;
|
|
||||||
target: ProjectBuildingSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectFloorUpdateCommandPayload {
|
interface ProjectFloorUpdateCommandPayload {
|
||||||
expected: ProjectFloorSnapshot;
|
expected: ProjectFloorSnapshot;
|
||||||
target: ProjectFloorSnapshot;
|
target: ProjectFloorSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LegacyProjectFloorUpdateCommandPayload {
|
|
||||||
expected: LegacyProjectFloorSnapshot;
|
|
||||||
target: LegacyProjectFloorSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectRoomUpdateCommandPayload {
|
interface ProjectRoomUpdateCommandPayload {
|
||||||
expected: ProjectRoomSnapshot;
|
expected: ProjectRoomSnapshot;
|
||||||
target: ProjectRoomSnapshot;
|
target: ProjectRoomSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectBuildingInsertProjectCommand
|
|
||||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
|
||||||
type: typeof projectBuildingInsertCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectBuildingDeleteProjectCommand
|
|
||||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
|
||||||
type: typeof projectBuildingDeleteCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectBuildingUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<ProjectBuildingUpdateCommandPayload> {
|
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
|
||||||
type: typeof projectBuildingUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectFloorInsertProjectCommand
|
export interface ProjectFloorInsertProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||||
type: typeof projectFloorInsertCommandType;
|
type: typeof projectFloorInsertCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectFloorDeleteProjectCommand
|
export interface ProjectFloorDeleteProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||||
type: typeof projectFloorDeleteCommandType;
|
type: typeof projectFloorDeleteCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectFloorUpdateProjectCommand
|
export interface ProjectFloorUpdateProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
|
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
|
||||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||||
type: typeof projectFloorUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegacyProjectFloorInsertProjectCommand
|
|
||||||
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
|
|
||||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
|
||||||
type: typeof projectFloorInsertCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegacyProjectFloorDeleteProjectCommand
|
|
||||||
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
|
|
||||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
|
||||||
type: typeof projectFloorDeleteCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegacyProjectFloorUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<LegacyProjectFloorUpdateCommandPayload> {
|
|
||||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
|
||||||
type: typeof projectFloorUpdateCommandType;
|
type: typeof projectFloorUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,46 +79,23 @@ export interface ProjectRoomUpdateProjectCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectLocationStructureProjectCommand =
|
export type ProjectLocationStructureProjectCommand =
|
||||||
| ProjectBuildingInsertProjectCommand
|
|
||||||
| ProjectBuildingDeleteProjectCommand
|
|
||||||
| ProjectBuildingUpdateProjectCommand
|
|
||||||
| ProjectFloorInsertProjectCommand
|
| ProjectFloorInsertProjectCommand
|
||||||
| ProjectFloorDeleteProjectCommand
|
| ProjectFloorDeleteProjectCommand
|
||||||
| ProjectFloorUpdateProjectCommand
|
| ProjectFloorUpdateProjectCommand
|
||||||
| LegacyProjectFloorInsertProjectCommand
|
|
||||||
| LegacyProjectFloorDeleteProjectCommand
|
|
||||||
| LegacyProjectFloorUpdateProjectCommand
|
|
||||||
| ProjectRoomInsertProjectCommand
|
| ProjectRoomInsertProjectCommand
|
||||||
| ProjectRoomDeleteProjectCommand
|
| ProjectRoomDeleteProjectCommand
|
||||||
| ProjectRoomUpdateProjectCommand;
|
| ProjectRoomUpdateProjectCommand;
|
||||||
|
|
||||||
export function createProjectBuildingSnapshot(
|
|
||||||
projectId: string,
|
|
||||||
name: string,
|
|
||||||
sortOrder: number
|
|
||||||
): ProjectBuildingSnapshot {
|
|
||||||
const building: ProjectBuildingSnapshot = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
projectId,
|
|
||||||
name: name.trim(),
|
|
||||||
sortOrder,
|
|
||||||
};
|
|
||||||
assertProjectBuildingSnapshot(building);
|
|
||||||
return building;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProjectFloorSnapshot(
|
export function createProjectFloorSnapshot(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
name: string,
|
name: string,
|
||||||
sortOrder: number,
|
sortOrder: number
|
||||||
buildingId: string | null = null
|
|
||||||
): ProjectFloorSnapshot {
|
): ProjectFloorSnapshot {
|
||||||
const floor: ProjectFloorSnapshot = {
|
const floor: ProjectFloorSnapshot = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
projectId,
|
projectId,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
sortOrder,
|
sortOrder,
|
||||||
buildingId: buildingId?.trim() || null,
|
|
||||||
};
|
};
|
||||||
assertProjectFloorSnapshot(floor);
|
assertProjectFloorSnapshot(floor);
|
||||||
return floor;
|
return floor;
|
||||||
|
|
@ -218,46 +120,12 @@ export function createProjectRoomSnapshot(
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectBuildingInsertProjectCommand(
|
|
||||||
building: ProjectBuildingSnapshot
|
|
||||||
): ProjectBuildingInsertProjectCommand {
|
|
||||||
assertProjectBuildingSnapshot(building);
|
|
||||||
return {
|
|
||||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
|
||||||
type: projectBuildingInsertCommandType,
|
|
||||||
payload: { building },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProjectBuildingDeleteProjectCommand(
|
|
||||||
building: ProjectBuildingSnapshot
|
|
||||||
): ProjectBuildingDeleteProjectCommand {
|
|
||||||
assertProjectBuildingSnapshot(building);
|
|
||||||
return {
|
|
||||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
|
||||||
type: projectBuildingDeleteCommandType,
|
|
||||||
payload: { building },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProjectBuildingUpdateProjectCommand(
|
|
||||||
expected: ProjectBuildingSnapshot,
|
|
||||||
target: ProjectBuildingSnapshot
|
|
||||||
): ProjectBuildingUpdateProjectCommand {
|
|
||||||
assertMatchingBuildingUpdate(expected, target);
|
|
||||||
return {
|
|
||||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
|
||||||
type: projectBuildingUpdateCommandType,
|
|
||||||
payload: { expected, target },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProjectFloorInsertProjectCommand(
|
export function createProjectFloorInsertProjectCommand(
|
||||||
floor: ProjectFloorSnapshot
|
floor: ProjectFloorSnapshot
|
||||||
): ProjectFloorInsertProjectCommand {
|
): ProjectFloorInsertProjectCommand {
|
||||||
assertProjectFloorSnapshot(floor);
|
assertProjectFloorSnapshot(floor);
|
||||||
return {
|
return {
|
||||||
schemaVersion: projectFloorCommandSchemaVersion,
|
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||||
type: projectFloorInsertCommandType,
|
type: projectFloorInsertCommandType,
|
||||||
payload: { floor },
|
payload: { floor },
|
||||||
};
|
};
|
||||||
|
|
@ -268,7 +136,7 @@ export function createProjectFloorDeleteProjectCommand(
|
||||||
): ProjectFloorDeleteProjectCommand {
|
): ProjectFloorDeleteProjectCommand {
|
||||||
assertProjectFloorSnapshot(floor);
|
assertProjectFloorSnapshot(floor);
|
||||||
return {
|
return {
|
||||||
schemaVersion: projectFloorCommandSchemaVersion,
|
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||||
type: projectFloorDeleteCommandType,
|
type: projectFloorDeleteCommandType,
|
||||||
payload: { floor },
|
payload: { floor },
|
||||||
};
|
};
|
||||||
|
|
@ -302,7 +170,7 @@ export function createProjectFloorUpdateProjectCommand(
|
||||||
): ProjectFloorUpdateProjectCommand {
|
): ProjectFloorUpdateProjectCommand {
|
||||||
assertMatchingFloorUpdate(expected, target);
|
assertMatchingFloorUpdate(expected, target);
|
||||||
return {
|
return {
|
||||||
schemaVersion: projectFloorCommandSchemaVersion,
|
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||||
type: projectFloorUpdateCommandType,
|
type: projectFloorUpdateCommandType,
|
||||||
payload: { expected, target },
|
payload: { expected, target },
|
||||||
};
|
};
|
||||||
|
|
@ -320,43 +188,9 @@ export function createProjectRoomUpdateProjectCommand(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertProjectBuildingInsertProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is ProjectBuildingInsertProjectCommand {
|
|
||||||
assertProjectBuildingStructureProjectCommand(
|
|
||||||
command,
|
|
||||||
projectBuildingInsertCommandType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertProjectBuildingDeleteProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is ProjectBuildingDeleteProjectCommand {
|
|
||||||
assertProjectBuildingStructureProjectCommand(
|
|
||||||
command,
|
|
||||||
projectBuildingDeleteCommandType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertProjectBuildingUpdateProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is ProjectBuildingUpdateProjectCommand {
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
|
||||||
command.type !== projectBuildingUpdateCommandType ||
|
|
||||||
!isPlainObject(command.payload) ||
|
|
||||||
Object.keys(command.payload).length !== 2
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported project-building update command.");
|
|
||||||
}
|
|
||||||
assertMatchingBuildingUpdate(command.payload.expected, command.payload.target);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertProjectFloorInsertProjectCommand(
|
export function assertProjectFloorInsertProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is
|
): asserts command is ProjectFloorInsertProjectCommand {
|
||||||
| ProjectFloorInsertProjectCommand
|
|
||||||
| LegacyProjectFloorInsertProjectCommand {
|
|
||||||
assertProjectFloorStructureProjectCommand(
|
assertProjectFloorStructureProjectCommand(
|
||||||
command,
|
command,
|
||||||
projectFloorInsertCommandType
|
projectFloorInsertCommandType
|
||||||
|
|
@ -365,9 +199,7 @@ export function assertProjectFloorInsertProjectCommand(
|
||||||
|
|
||||||
export function assertProjectFloorDeleteProjectCommand(
|
export function assertProjectFloorDeleteProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is
|
): asserts command is ProjectFloorDeleteProjectCommand {
|
||||||
| ProjectFloorDeleteProjectCommand
|
|
||||||
| LegacyProjectFloorDeleteProjectCommand {
|
|
||||||
assertProjectFloorStructureProjectCommand(
|
assertProjectFloorStructureProjectCommand(
|
||||||
command,
|
command,
|
||||||
projectFloorDeleteCommandType
|
projectFloorDeleteCommandType
|
||||||
|
|
@ -394,26 +226,15 @@ export function assertProjectRoomDeleteProjectCommand(
|
||||||
|
|
||||||
export function assertProjectFloorUpdateProjectCommand(
|
export function assertProjectFloorUpdateProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is
|
): asserts command is ProjectFloorUpdateProjectCommand {
|
||||||
| ProjectFloorUpdateProjectCommand
|
|
||||||
| LegacyProjectFloorUpdateProjectCommand {
|
|
||||||
if (
|
if (
|
||||||
|
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||||
command.type !== projectFloorUpdateCommandType ||
|
command.type !== projectFloorUpdateCommandType ||
|
||||||
!isPlainObject(command.payload) ||
|
!isPlainObject(command.payload) ||
|
||||||
Object.keys(command.payload).length !== 2
|
Object.keys(command.payload).length !== 2
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project-floor update command.");
|
throw new Error("Unsupported project-floor update command.");
|
||||||
}
|
}
|
||||||
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
|
|
||||||
assertMatchingFloorUpdate(
|
|
||||||
upgradeLegacyFloorSnapshot(command.payload.expected),
|
|
||||||
upgradeLegacyFloorSnapshot(command.payload.target)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
|
|
||||||
throw new Error("Unsupported project-floor update command.");
|
|
||||||
}
|
|
||||||
assertMatchingFloorUpdate(command.payload.expected, command.payload.target);
|
assertMatchingFloorUpdate(command.payload.expected, command.payload.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,68 +252,6 @@ export function assertProjectRoomUpdateProjectCommand(
|
||||||
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
|
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upgrades a (validated) floor command of any supported version to the
|
|
||||||
* current version. Version 1 commands predate buildings; their floors had no
|
|
||||||
* building, so `buildingId` becomes null.
|
|
||||||
*/
|
|
||||||
export function upgradeProjectFloorCommand(
|
|
||||||
command:
|
|
||||||
| ProjectFloorInsertProjectCommand
|
|
||||||
| LegacyProjectFloorInsertProjectCommand
|
|
||||||
): ProjectFloorInsertProjectCommand;
|
|
||||||
export function upgradeProjectFloorCommand(
|
|
||||||
command:
|
|
||||||
| ProjectFloorDeleteProjectCommand
|
|
||||||
| LegacyProjectFloorDeleteProjectCommand
|
|
||||||
): ProjectFloorDeleteProjectCommand;
|
|
||||||
export function upgradeProjectFloorCommand(
|
|
||||||
command:
|
|
||||||
| ProjectFloorUpdateProjectCommand
|
|
||||||
| LegacyProjectFloorUpdateProjectCommand
|
|
||||||
): ProjectFloorUpdateProjectCommand;
|
|
||||||
export function upgradeProjectFloorCommand(
|
|
||||||
command:
|
|
||||||
| ProjectFloorInsertProjectCommand
|
|
||||||
| ProjectFloorDeleteProjectCommand
|
|
||||||
| ProjectFloorUpdateProjectCommand
|
|
||||||
| LegacyProjectFloorInsertProjectCommand
|
|
||||||
| LegacyProjectFloorDeleteProjectCommand
|
|
||||||
| LegacyProjectFloorUpdateProjectCommand
|
|
||||||
) {
|
|
||||||
if (command.schemaVersion === projectFloorCommandSchemaVersion) {
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
switch (command.type) {
|
|
||||||
case projectFloorInsertCommandType:
|
|
||||||
return createProjectFloorInsertProjectCommand(
|
|
||||||
upgradeLegacyFloorSnapshot(command.payload.floor)
|
|
||||||
);
|
|
||||||
case projectFloorDeleteCommandType:
|
|
||||||
return createProjectFloorDeleteProjectCommand(
|
|
||||||
upgradeLegacyFloorSnapshot(command.payload.floor)
|
|
||||||
);
|
|
||||||
case projectFloorUpdateCommandType:
|
|
||||||
return createProjectFloorUpdateProjectCommand(
|
|
||||||
upgradeLegacyFloorSnapshot(command.payload.expected),
|
|
||||||
upgradeLegacyFloorSnapshot(command.payload.target)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
|
|
||||||
assertProjectBuildingSnapshot(expected);
|
|
||||||
assertProjectBuildingSnapshot(target);
|
|
||||||
if (
|
|
||||||
expected.id !== target.id ||
|
|
||||||
expected.projectId !== target.projectId ||
|
|
||||||
expected.sortOrder !== target.sortOrder ||
|
|
||||||
expected.name === target.name
|
|
||||||
) {
|
|
||||||
throw new Error("Project-building update must change only the name.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
|
function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
|
||||||
assertProjectFloorSnapshot(expected);
|
assertProjectFloorSnapshot(expected);
|
||||||
assertProjectFloorSnapshot(target);
|
assertProjectFloorSnapshot(target);
|
||||||
|
|
@ -500,11 +259,9 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
|
||||||
expected.id !== target.id ||
|
expected.id !== target.id ||
|
||||||
expected.projectId !== target.projectId ||
|
expected.projectId !== target.projectId ||
|
||||||
expected.sortOrder !== target.sortOrder ||
|
expected.sortOrder !== target.sortOrder ||
|
||||||
(expected.name === target.name && expected.buildingId === target.buildingId)
|
expected.name === target.name
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error("Project-floor update must change only the name.");
|
||||||
"Project-floor update must change only the name or the building."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -520,49 +277,22 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertProjectBuildingSnapshot(
|
|
||||||
building: unknown
|
|
||||||
): asserts building is ProjectBuildingSnapshot {
|
|
||||||
if (!isPlainObject(building) || Object.keys(building).length !== 4) {
|
|
||||||
throw new Error("Project-building snapshot is invalid.");
|
|
||||||
}
|
|
||||||
assertNormalizedNonEmptyString(building.id, "building.id");
|
|
||||||
assertNormalizedNonEmptyString(building.projectId, "building.projectId");
|
|
||||||
assertNormalizedNonEmptyString(building.name, "building.name");
|
|
||||||
assertSortOrder(building.sortOrder, "building.sortOrder");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertProjectFloorSnapshot(
|
export function assertProjectFloorSnapshot(
|
||||||
floor: unknown
|
floor: unknown
|
||||||
): asserts floor is ProjectFloorSnapshot {
|
): asserts floor is ProjectFloorSnapshot {
|
||||||
if (!isPlainObject(floor) || Object.keys(floor).length !== 5) {
|
|
||||||
throw new Error("Project-floor snapshot is invalid.");
|
|
||||||
}
|
|
||||||
assertLegacyFloorFields(floor);
|
|
||||||
if (floor.buildingId !== null) {
|
|
||||||
assertNormalizedNonEmptyString(floor.buildingId, "floor.buildingId");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertLegacyProjectFloorSnapshot(
|
|
||||||
floor: unknown
|
|
||||||
): asserts floor is LegacyProjectFloorSnapshot {
|
|
||||||
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
|
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
|
||||||
throw new Error("Project-floor snapshot is invalid.");
|
throw new Error("Project-floor snapshot is invalid.");
|
||||||
}
|
}
|
||||||
assertLegacyFloorFields(floor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertLegacyFloorFields(floor: Record<string, unknown>) {
|
|
||||||
assertNormalizedNonEmptyString(floor.id, "floor.id");
|
assertNormalizedNonEmptyString(floor.id, "floor.id");
|
||||||
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
|
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
|
||||||
assertNormalizedNonEmptyString(floor.name, "floor.name");
|
assertNormalizedNonEmptyString(floor.name, "floor.name");
|
||||||
assertSortOrder(floor.sortOrder, "floor.sortOrder");
|
if (
|
||||||
}
|
typeof floor.sortOrder !== "number" ||
|
||||||
|
!Number.isSafeInteger(floor.sortOrder) ||
|
||||||
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
|
floor.sortOrder < 0
|
||||||
assertLegacyProjectFloorSnapshot(floor);
|
) {
|
||||||
return { ...floor, buildingId: null };
|
throw new Error("floor.sortOrder must be a non-negative integer.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertProjectRoomSnapshot(
|
export function assertProjectRoomSnapshot(
|
||||||
|
|
@ -580,23 +310,6 @@ export function assertProjectRoomSnapshot(
|
||||||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertProjectBuildingStructureProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>,
|
|
||||||
expectedType:
|
|
||||||
| typeof projectBuildingInsertCommandType
|
|
||||||
| typeof projectBuildingDeleteCommandType
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
|
||||||
command.type !== expectedType ||
|
|
||||||
!isPlainObject(command.payload) ||
|
|
||||||
Object.keys(command.payload).length !== 1
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported project-building structure command.");
|
|
||||||
}
|
|
||||||
assertProjectBuildingSnapshot(command.payload.building);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertProjectFloorStructureProjectCommand(
|
function assertProjectFloorStructureProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>,
|
command: SerializedProjectCommand<unknown>,
|
||||||
expectedType:
|
expectedType:
|
||||||
|
|
@ -604,19 +317,13 @@ function assertProjectFloorStructureProjectCommand(
|
||||||
| typeof projectFloorDeleteCommandType
|
| typeof projectFloorDeleteCommandType
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
|
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||||
command.type !== expectedType ||
|
command.type !== expectedType ||
|
||||||
!isPlainObject(command.payload) ||
|
!isPlainObject(command.payload) ||
|
||||||
Object.keys(command.payload).length !== 1
|
Object.keys(command.payload).length !== 1
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project-floor structure command.");
|
throw new Error("Unsupported project-floor structure command.");
|
||||||
}
|
}
|
||||||
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
|
|
||||||
assertLegacyProjectFloorSnapshot(command.payload.floor);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
|
|
||||||
throw new Error("Unsupported project-floor structure command.");
|
|
||||||
}
|
|
||||||
assertProjectFloorSnapshot(command.payload.floor);
|
assertProjectFloorSnapshot(command.payload.floor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -637,16 +344,6 @@ function assertProjectRoomStructureProjectCommand(
|
||||||
assertProjectRoomSnapshot(command.payload.room);
|
assertProjectRoomSnapshot(command.payload.room);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertSortOrder(value: unknown, field: string) {
|
|
||||||
if (
|
|
||||||
typeof value !== "number" ||
|
|
||||||
!Number.isSafeInteger(value) ||
|
|
||||||
value < 0
|
|
||||||
) {
|
|
||||||
throw new Error(`${field} must be a non-negative integer.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNormalizedNonEmptyString(
|
function assertNormalizedNonEmptyString(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
field: string
|
field: string
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,13 @@ import {
|
||||||
distributionBoardSupplyTypes,
|
distributionBoardSupplyTypes,
|
||||||
type DistributionBoardSupplyType,
|
type DistributionBoardSupplyType,
|
||||||
} from "../../shared/constants/distribution-board.js";
|
} from "../../shared/constants/distribution-board.js";
|
||||||
import {
|
|
||||||
projectCoverFieldMaxLengths,
|
|
||||||
projectCoverFields,
|
|
||||||
type ProjectCoverField,
|
|
||||||
} from "../../shared/constants/project-cover.js";
|
|
||||||
|
|
||||||
export const projectSettingsUpdateCommandType =
|
export const projectSettingsUpdateCommandType =
|
||||||
"project.update-settings" as const;
|
"project.update-settings" as const;
|
||||||
// Version 3 adds the print cover fields. Versions 2 (without cover fields)
|
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||||
// and 1 (additionally without the public-building flag) stay executable;
|
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||||
// their missing values keep the current project values.
|
|
||||||
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
|
||||||
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
|
||||||
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ProjectSettingsValues
|
export interface ProjectSettingsValues {
|
||||||
extends Record<ProjectCoverField, string | null> {
|
|
||||||
name: string;
|
name: string;
|
||||||
internalProjectNumber: string | null;
|
internalProjectNumber: string | null;
|
||||||
externalProjectNumber: string | null;
|
externalProjectNumber: string | null;
|
||||||
|
|
@ -31,13 +21,8 @@ export interface ProjectSettingsValues
|
||||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WithoutCoverProjectSettingsValues = Omit<
|
type PreviousProjectSettingsValues = Omit<
|
||||||
ProjectSettingsValues,
|
ProjectSettingsValues,
|
||||||
ProjectCoverField
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type BaselineProjectSettingsValues = Omit<
|
|
||||||
WithoutCoverProjectSettingsValues,
|
|
||||||
"isPublicBuilding"
|
"isPublicBuilding"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
@ -47,22 +32,15 @@ export interface CurrentProjectSettingsUpdateProjectCommand
|
||||||
type: typeof projectSettingsUpdateCommandType;
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithoutCoverProjectSettingsUpdateProjectCommand
|
interface PreviousProjectSettingsUpdateProjectCommand
|
||||||
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
|
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||||
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
|
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||||
type: typeof projectSettingsUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BaselineProjectSettingsUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<BaselineProjectSettingsValues> {
|
|
||||||
schemaVersion: typeof baselineProjectSettingsUpdateCommandSchemaVersion;
|
|
||||||
type: typeof projectSettingsUpdateCommandType;
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectSettingsUpdateProjectCommand =
|
export type ProjectSettingsUpdateProjectCommand =
|
||||||
| CurrentProjectSettingsUpdateProjectCommand
|
| CurrentProjectSettingsUpdateProjectCommand
|
||||||
| WithoutCoverProjectSettingsUpdateProjectCommand
|
| PreviousProjectSettingsUpdateProjectCommand;
|
||||||
| BaselineProjectSettingsUpdateProjectCommand;
|
|
||||||
|
|
||||||
export function createProjectSettingsUpdateProjectCommand(
|
export function createProjectSettingsUpdateProjectCommand(
|
||||||
values: ProjectSettingsValues
|
values: ProjectSettingsValues
|
||||||
|
|
@ -82,10 +60,7 @@ export function assertProjectSettingsUpdateProjectCommand(
|
||||||
if (
|
if (
|
||||||
command.type !== projectSettingsUpdateCommandType ||
|
command.type !== projectSettingsUpdateCommandType ||
|
||||||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
|
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
|
||||||
command.schemaVersion !==
|
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion)
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
|
|
||||||
command.schemaVersion !==
|
|
||||||
baselineProjectSettingsUpdateCommandSchemaVersion)
|
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project settings update command.");
|
throw new Error("Unsupported project settings update command.");
|
||||||
}
|
}
|
||||||
|
|
@ -116,23 +91,13 @@ export function assertProjectSettingsUpdateProjectCommand(
|
||||||
"Project settings update command contains invalid supply types."
|
"Project settings update command contains invalid supply types."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const payload = command.payload;
|
const isPreviousSchema =
|
||||||
const expectedKeyCount =
|
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion;
|
||||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? 8
|
|
||||||
: command.schemaVersion ===
|
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? 9
|
|
||||||
: 9 + projectCoverFields.length;
|
|
||||||
if (
|
if (
|
||||||
Object.keys(payload).length !== expectedKeyCount ||
|
(isPreviousSchema && Object.keys(command.payload).length !== 8) ||
|
||||||
(command.schemaVersion !==
|
(!isPreviousSchema &&
|
||||||
baselineProjectSettingsUpdateCommandSchemaVersion &&
|
(typeof command.payload.isPublicBuilding !== "boolean" ||
|
||||||
typeof payload.isPublicBuilding !== "boolean") ||
|
Object.keys(command.payload).length !== 9))
|
||||||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
|
|
||||||
!projectCoverFields.every((field) =>
|
|
||||||
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
|
|
||||||
))
|
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Project settings update command contains invalid values."
|
"Project settings update command contains invalid values."
|
||||||
|
|
|
||||||
|
|
@ -3,80 +3,32 @@ import {
|
||||||
type ProjectStateSnapshot,
|
type ProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.model.js";
|
} from "./project-state-snapshot.model.js";
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
import {
|
|
||||||
parseEstimateBalanceSnapshot,
|
|
||||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
|
||||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
|
|
||||||
export const projectStateRestoreCommandType = "project.restore-state";
|
export const projectStateRestoreCommandType = "project.restore-state";
|
||||||
export const projectStateRestoreCommandSchemaVersion = 2 as const;
|
export const projectStateRestoreCommandSchemaVersion = 1 as const;
|
||||||
const snapshotRestoreCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ProjectStateRestoreCommandPayload {
|
export interface ProjectStateRestoreCommandPayload {
|
||||||
expectedStateSha256: string;
|
expectedStateSha256: string;
|
||||||
targetState: ProjectStateSnapshot;
|
targetState: ProjectStateSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
|
export type ProjectStateRestoreCommand = SerializedProjectCommand<
|
||||||
ProjectStateRestoreCommandPayload
|
ProjectStateRestoreCommandPayload
|
||||||
> & {
|
|
||||||
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
|
|
||||||
type: typeof projectStateRestoreCommandType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ProjectTransferRestoreCommandPayload
|
|
||||||
extends ProjectStateRestoreCommandPayload {
|
|
||||||
expectedEstimateBalances: EstimateBalanceSnapshot[];
|
|
||||||
targetEstimateBalances: EstimateBalanceSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
|
|
||||||
ProjectTransferRestoreCommandPayload
|
|
||||||
> & {
|
> & {
|
||||||
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
||||||
type: typeof projectStateRestoreCommandType;
|
type: typeof projectStateRestoreCommandType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectStateRestoreCommand =
|
|
||||||
| SnapshotProjectStateRestoreCommand
|
|
||||||
| ProjectTransferRestoreCommand;
|
|
||||||
|
|
||||||
export function createProjectStateRestoreCommand(
|
export function createProjectStateRestoreCommand(
|
||||||
expectedStateSha256: string,
|
expectedStateSha256: string,
|
||||||
targetState: ProjectStateSnapshot
|
targetState: ProjectStateSnapshot
|
||||||
): ProjectStateRestoreCommand {
|
): ProjectStateRestoreCommand {
|
||||||
const command: SnapshotProjectStateRestoreCommand = {
|
const command: ProjectStateRestoreCommand = {
|
||||||
schemaVersion: snapshotRestoreCommandSchemaVersion,
|
|
||||||
type: projectStateRestoreCommandType,
|
|
||||||
payload: {
|
|
||||||
expectedStateSha256,
|
|
||||||
targetState: parseProjectStateSnapshot(targetState),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
assertProjectStateRestoreCommand(command);
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProjectTransferRestoreCommand(
|
|
||||||
expectedStateSha256: string,
|
|
||||||
targetState: ProjectStateSnapshot,
|
|
||||||
expectedEstimateBalances: EstimateBalanceSnapshot[],
|
|
||||||
targetEstimateBalances: EstimateBalanceSnapshot[]
|
|
||||||
): ProjectTransferRestoreCommand {
|
|
||||||
const command: ProjectTransferRestoreCommand = {
|
|
||||||
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
||||||
type: projectStateRestoreCommandType,
|
type: projectStateRestoreCommandType,
|
||||||
payload: {
|
payload: {
|
||||||
expectedStateSha256,
|
expectedStateSha256,
|
||||||
targetState: parseProjectStateSnapshot(targetState),
|
targetState: parseProjectStateSnapshot(targetState),
|
||||||
expectedEstimateBalances: parseEstimateBalances(
|
|
||||||
expectedEstimateBalances,
|
|
||||||
targetState.project.id
|
|
||||||
),
|
|
||||||
targetEstimateBalances: parseEstimateBalances(
|
|
||||||
targetEstimateBalances,
|
|
||||||
targetState.project.id
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
assertProjectStateRestoreCommand(command);
|
assertProjectStateRestoreCommand(command);
|
||||||
|
|
@ -90,7 +42,10 @@ export function assertProjectStateRestoreCommand(
|
||||||
throw new Error("Project state restore command must be an object.");
|
throw new Error("Project state restore command must be an object.");
|
||||||
}
|
}
|
||||||
const command = value as Partial<ProjectStateRestoreCommand>;
|
const command = value as Partial<ProjectStateRestoreCommand>;
|
||||||
if (command.type !== projectStateRestoreCommandType) {
|
if (
|
||||||
|
command.type !== projectStateRestoreCommandType ||
|
||||||
|
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
|
||||||
|
) {
|
||||||
throw new Error("Unsupported project state restore command.");
|
throw new Error("Unsupported project state restore command.");
|
||||||
}
|
}
|
||||||
if (!command.payload || typeof command.payload !== "object") {
|
if (!command.payload || typeof command.payload !== "object") {
|
||||||
|
|
@ -103,43 +58,5 @@ export function assertProjectStateRestoreCommand(
|
||||||
) {
|
) {
|
||||||
throw new Error("Expected project state checksum is invalid.");
|
throw new Error("Expected project state checksum is invalid.");
|
||||||
}
|
}
|
||||||
const targetState = parseProjectStateSnapshot(payload.targetState);
|
parseProjectStateSnapshot(payload.targetState);
|
||||||
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
|
|
||||||
if (Object.keys(payload).length !== 2) {
|
|
||||||
throw new Error("Snapshot restore payload contains unknown fields.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
|
|
||||||
throw new Error("Unsupported project state restore command.");
|
|
||||||
}
|
|
||||||
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
|
|
||||||
parseEstimateBalances(
|
|
||||||
transferPayload.expectedEstimateBalances,
|
|
||||||
targetState.project.id
|
|
||||||
);
|
|
||||||
parseEstimateBalances(
|
|
||||||
transferPayload.targetEstimateBalances,
|
|
||||||
targetState.project.id
|
|
||||||
);
|
|
||||||
if (Object.keys(payload).length !== 4) {
|
|
||||||
throw new Error("Project transfer restore payload contains unknown fields.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEstimateBalances(
|
|
||||||
value: unknown,
|
|
||||||
projectId: string
|
|
||||||
): EstimateBalanceSnapshot[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
throw new Error("Project transfer estimate balances must be an array.");
|
|
||||||
}
|
|
||||||
const balances = value.map(parseEstimateBalanceSnapshot);
|
|
||||||
if (balances.some((balance) => balance.projectId !== projectId)) {
|
|
||||||
throw new Error("Project transfer estimate balance belongs to another project.");
|
|
||||||
}
|
|
||||||
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
|
|
||||||
throw new Error("Project transfer contains duplicate estimate balances.");
|
|
||||||
}
|
|
||||||
return balances;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
|
||||||
distributionBoardInfoFieldMaxLengths,
|
|
||||||
distributionBoardSupplyTypes,
|
|
||||||
type DistributionBoardInfoField,
|
|
||||||
} from "../../shared/constants/distribution-board.js";
|
|
||||||
import {
|
|
||||||
projectCoverFieldMaxLengths,
|
|
||||||
type ProjectCoverField,
|
|
||||||
} from "../../shared/constants/project-cover.js";
|
|
||||||
import {
|
import {
|
||||||
circuitGroupCategories,
|
circuitGroupCategories,
|
||||||
} from "../../shared/constants/circuit-group.js";
|
} from "../../shared/constants/circuit-group.js";
|
||||||
|
|
@ -35,9 +27,7 @@ import {
|
||||||
type ExternalModelStateSnapshot,
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
export const projectStateSnapshotSchemaVersion = 5 as const;
|
||||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
|
||||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
|
||||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||||
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
|
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
|
||||||
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
|
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
|
||||||
|
|
@ -68,31 +58,11 @@ const previousProjectSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const publicBuildingProjectSchema = previousProjectSchema
|
const projectSchema = previousProjectSchema
|
||||||
.extend({ isPublicBuilding: z.boolean() })
|
.extend({ isPublicBuilding: z.boolean() })
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const projectCoverValueSchema = (field: ProjectCoverField) =>
|
const distributionBoardSchema = z
|
||||||
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
|
|
||||||
|
|
||||||
// Schema version 6 adds the print cover fields.
|
|
||||||
const projectSchema = publicBuildingProjectSchema
|
|
||||||
.extend({
|
|
||||||
siteAddress: projectCoverValueSchema("siteAddress"),
|
|
||||||
workStage: projectCoverValueSchema("workStage"),
|
|
||||||
projectLead: projectCoverValueSchema("projectLead"),
|
|
||||||
projectEditor: projectCoverValueSchema("projectEditor"),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const emptyProjectCoverValues = {
|
|
||||||
siteAddress: null,
|
|
||||||
workStage: null,
|
|
||||||
projectLead: null,
|
|
||||||
projectEditor: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const legacyDistributionBoardSchema = z
|
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
|
|
@ -103,35 +73,6 @@ const legacyDistributionBoardSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
|
|
||||||
z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(distributionBoardInfoFieldMaxLengths[field])
|
|
||||||
.nullable();
|
|
||||||
|
|
||||||
const distributionBoardSchema = legacyDistributionBoardSchema
|
|
||||||
.extend({
|
|
||||||
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
|
|
||||||
location: distributionBoardInfoValueSchema("location"),
|
|
||||||
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
|
|
||||||
remark: distributionBoardInfoValueSchema("remark"),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
function withEmptyDistributionBoardInfo(
|
|
||||||
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
|
|
||||||
) {
|
|
||||||
return boards.map((board) => ({
|
|
||||||
...board,
|
|
||||||
boardNumber: null,
|
|
||||||
location: null,
|
|
||||||
fedFrom: null,
|
|
||||||
remark: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const circuitListSchema = z
|
const circuitListSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
|
|
@ -238,7 +179,7 @@ const projectDeviceSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const buildingSchema = z
|
const floorSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
|
|
@ -247,19 +188,6 @@ const buildingSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const legacyFloorSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
name: z.string().trim().min(1),
|
|
||||||
sortOrder: finiteNumberSchema,
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const floorSchema = legacyFloorSchema
|
|
||||||
.extend({ buildingId: idSchema.nullable() })
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const roomSchema = z
|
const roomSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
|
|
@ -504,24 +432,11 @@ export function parseExternalModelStateSnapshot(
|
||||||
return externalModelStateSchema.parse(value);
|
return externalModelStateSchema.parse(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Versions 1-6 have floors without a building and no building list.
|
const projectStateSnapshotContents = {
|
||||||
const previousProjectStateSnapshotContents = {
|
|
||||||
circuitLists: z.array(circuitListSchema),
|
circuitLists: z.array(circuitListSchema),
|
||||||
circuitSections: z.array(circuitSectionSchema),
|
circuitSections: z.array(circuitSectionSchema),
|
||||||
circuits: z.array(circuitSchema),
|
circuits: z.array(circuitSchema),
|
||||||
projectDevices: z.array(projectDeviceSchema),
|
projectDevices: z.array(projectDeviceSchema),
|
||||||
floors: z.array(legacyFloorSchema),
|
|
||||||
rooms: z.array(roomSchema),
|
|
||||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
|
||||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
|
||||||
distributionBoardComponentProtectionDevices: z.array(
|
|
||||||
componentProtectionDeviceSchema
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectStateSnapshotContents = {
|
|
||||||
...previousProjectStateSnapshotContents,
|
|
||||||
buildings: z.array(buildingSchema),
|
|
||||||
floors: z.array(floorSchema),
|
floors: z.array(floorSchema),
|
||||||
rooms: z.array(roomSchema),
|
rooms: z.array(roomSchema),
|
||||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||||
|
|
@ -542,55 +457,33 @@ export const projectStateSnapshotSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const coverProjectStateSnapshotSchema = z
|
const previousProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||||
project: projectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...previousProjectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const boardInfoProjectStateSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
|
|
||||||
project: publicBuildingProjectSchema,
|
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
|
||||||
externalModel: externalModelStateSchema,
|
|
||||||
...previousProjectStateSnapshotContents,
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const previousProjectStateSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
|
||||||
project: publicBuildingProjectSchema,
|
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
|
||||||
externalModel: externalModelStateSchema,
|
|
||||||
...previousProjectStateSnapshotContents,
|
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const externalModelProjectStateSnapshotSchema = z
|
const externalModelProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
||||||
project: publicBuildingProjectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
...previousProjectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const legacyProjectStateSnapshotSchema = z
|
const legacyProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||||
project: publicBuildingProjectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...previousProjectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
@ -598,8 +491,8 @@ const baselineProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
||||||
project: previousProjectSchema,
|
project: previousProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...previousProjectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
@ -607,20 +500,6 @@ export type ProjectStateSnapshot = z.infer<
|
||||||
typeof projectStateSnapshotSchema
|
typeof projectStateSnapshotSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Stored snapshots of every supported version can be restored: they are
|
|
||||||
* upgraded to the current version while parsing.
|
|
||||||
*/
|
|
||||||
export function isSupportedProjectStateSnapshotSchemaVersion(
|
|
||||||
schemaVersion: number
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
Number.isInteger(schemaVersion) &&
|
|
||||||
schemaVersion >= baselineProjectStateSnapshotSchemaVersion &&
|
|
||||||
schemaVersion <= projectStateSnapshotSchemaVersion
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseProjectStateSnapshot(
|
export function parseProjectStateSnapshot(
|
||||||
value: unknown
|
value: unknown
|
||||||
): ProjectStateSnapshot {
|
): ProjectStateSnapshot {
|
||||||
|
|
@ -633,28 +512,6 @@ export function parseProjectStateSnapshot(
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
const cover = upgradeToCoverProjectStateSnapshot(value);
|
|
||||||
if (
|
|
||||||
cover === null ||
|
|
||||||
typeof cover !== "object" ||
|
|
||||||
Array.isArray(cover) ||
|
|
||||||
(cover as { schemaVersion?: unknown }).schemaVersion !==
|
|
||||||
coverProjectStateSnapshotSchemaVersion
|
|
||||||
) {
|
|
||||||
return cover;
|
|
||||||
}
|
|
||||||
// Every supported version before 7 lacks buildings; its floors had no
|
|
||||||
// building.
|
|
||||||
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
|
||||||
buildings: [],
|
|
||||||
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
|
||||||
if (
|
if (
|
||||||
value === null ||
|
value === null ||
|
||||||
typeof value !== "object" ||
|
typeof value !== "object" ||
|
||||||
|
|
@ -664,40 +521,18 @@ function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
|
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
|
||||||
// Every supported version before 6 lacks the distribution-board info
|
|
||||||
// fields and the project cover fields; they are upgraded as empty (null)
|
|
||||||
// values.
|
|
||||||
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
|
|
||||||
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
|
|
||||||
return {
|
|
||||||
...previous,
|
|
||||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
|
||||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
previous.distributionBoards
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
|
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
|
||||||
const previous = previousProjectStateSnapshotSchema.parse(value);
|
const previous = previousProjectStateSnapshotSchema.parse(value);
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
previous.distributionBoards
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
|
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
|
||||||
const previous = externalModelProjectStateSnapshotSchema.parse(value);
|
const previous = externalModelProjectStateSnapshotSchema.parse(value);
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
previous.distributionBoards
|
|
||||||
),
|
|
||||||
externalModel: emptyExternalModelState(),
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -705,11 +540,7 @@ function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||||
const legacy = legacyProjectStateSnapshotSchema.parse(value);
|
const legacy = legacyProjectStateSnapshotSchema.parse(value);
|
||||||
return {
|
return {
|
||||||
...legacy,
|
...legacy,
|
||||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...legacy.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
legacy.distributionBoards
|
|
||||||
),
|
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
externalModel: emptyExternalModelState(),
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
|
|
@ -718,15 +549,8 @@ function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||||
const baseline = baselineProjectStateSnapshotSchema.parse(value);
|
const baseline = baselineProjectStateSnapshotSchema.parse(value);
|
||||||
return {
|
return {
|
||||||
...baseline,
|
...baseline,
|
||||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: {
|
project: { ...baseline.project, isPublicBuilding: false },
|
||||||
...baseline.project,
|
|
||||||
isPublicBuilding: false,
|
|
||||||
...emptyProjectCoverValues,
|
|
||||||
},
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
baseline.distributionBoards
|
|
||||||
),
|
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
externalModel: emptyExternalModelState(),
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
|
|
@ -825,7 +649,6 @@ function assertProjectStateSnapshotRelations(
|
||||||
snapshot.projectDevices,
|
snapshot.projectDevices,
|
||||||
"project device"
|
"project device"
|
||||||
);
|
);
|
||||||
const buildingIds = uniqueIds(snapshot.buildings, "building");
|
|
||||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||||
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
|
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
|
||||||
|
|
@ -904,14 +727,8 @@ function assertProjectStateSnapshotRelations(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const building of snapshot.buildings) {
|
|
||||||
assertProjectOwnership(building.projectId, projectId, "building");
|
|
||||||
}
|
|
||||||
for (const floor of snapshot.floors) {
|
for (const floor of snapshot.floors) {
|
||||||
assertProjectOwnership(floor.projectId, projectId, "floor");
|
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||||
if (floor.buildingId !== null) {
|
|
||||||
assertReference(buildingIds, floor.buildingId, "floor building");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const room of snapshot.rooms) {
|
for (const room of snapshot.rooms) {
|
||||||
assertProjectOwnership(room.projectId, projectId, "room");
|
assertProjectOwnership(room.projectId, projectId, "room");
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,9 @@ import {
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
type ProjectStateSnapshot,
|
type ProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.model.js";
|
} from "./project-state-snapshot.model.js";
|
||||||
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
|
||||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
|
|
||||||
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||||
export const projectTransferFormatVersion = 2 as const;
|
export const projectTransferFormatVersion = 1 as const;
|
||||||
const legacyProjectTransferFormatVersion = 1 as const;
|
|
||||||
|
|
||||||
export interface ProjectTransferEnvelope {
|
export interface ProjectTransferEnvelope {
|
||||||
format: typeof projectTransferFormat;
|
format: typeof projectTransferFormat;
|
||||||
|
|
@ -16,14 +13,12 @@ export interface ProjectTransferEnvelope {
|
||||||
exportedAtIso: string;
|
exportedAtIso: string;
|
||||||
payloadSha256: string;
|
payloadSha256: string;
|
||||||
projectState: ProjectStateSnapshot;
|
projectState: ProjectStateSnapshot;
|
||||||
estimateBalances: EstimateBalanceSnapshot[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectTransferEnvelope(
|
export function createProjectTransferEnvelope(
|
||||||
exportedAtIso: string,
|
exportedAtIso: string,
|
||||||
payloadSha256: string,
|
payloadSha256: string,
|
||||||
projectState: ProjectStateSnapshot,
|
projectState: ProjectStateSnapshot
|
||||||
estimateBalances: EstimateBalanceSnapshot[]
|
|
||||||
): ProjectTransferEnvelope {
|
): ProjectTransferEnvelope {
|
||||||
return parseProjectTransferEnvelope({
|
return parseProjectTransferEnvelope({
|
||||||
format: projectTransferFormat,
|
format: projectTransferFormat,
|
||||||
|
|
@ -31,7 +26,6 @@ export function createProjectTransferEnvelope(
|
||||||
exportedAtIso,
|
exportedAtIso,
|
||||||
payloadSha256,
|
payloadSha256,
|
||||||
projectState,
|
projectState,
|
||||||
estimateBalances,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,8 +37,7 @@ export function parseProjectTransferEnvelope(
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
value.format !== projectTransferFormat ||
|
value.format !== projectTransferFormat ||
|
||||||
(value.formatVersion !== legacyProjectTransferFormatVersion &&
|
value.formatVersion !== projectTransferFormatVersion
|
||||||
value.formatVersion !== projectTransferFormatVersion)
|
|
||||||
) {
|
) {
|
||||||
throw new Error("Project transfer format is not supported.");
|
throw new Error("Project transfer format is not supported.");
|
||||||
}
|
}
|
||||||
|
|
@ -64,13 +57,7 @@ export function parseProjectTransferEnvelope(
|
||||||
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
throw new Error("Project transfer snapshot version is not supported.");
|
throw new Error("Project transfer snapshot version is not supported.");
|
||||||
}
|
}
|
||||||
const estimateBalances =
|
if (Object.keys(value).length !== 5) {
|
||||||
value.formatVersion === legacyProjectTransferFormatVersion
|
|
||||||
? []
|
|
||||||
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
|
|
||||||
const expectedFieldCount =
|
|
||||||
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
|
|
||||||
if (Object.keys(value).length !== expectedFieldCount) {
|
|
||||||
throw new Error("Project transfer file contains unknown fields.");
|
throw new Error("Project transfer file contains unknown fields.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -79,43 +66,9 @@ export function parseProjectTransferEnvelope(
|
||||||
exportedAtIso: value.exportedAtIso,
|
exportedAtIso: value.exportedAtIso,
|
||||||
payloadSha256: value.payloadSha256,
|
payloadSha256: value.payloadSha256,
|
||||||
projectState,
|
projectState,
|
||||||
estimateBalances,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeProjectTransferPayload(
|
|
||||||
projectState: ProjectStateSnapshot,
|
|
||||||
estimateBalances: EstimateBalanceSnapshot[]
|
|
||||||
) {
|
|
||||||
const parsedState = parseProjectStateSnapshot(projectState);
|
|
||||||
return JSON.stringify({
|
|
||||||
projectState: parsedState,
|
|
||||||
estimateBalances: parseTransferEstimateBalances(
|
|
||||||
estimateBalances,
|
|
||||||
parsedState.project.id
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remapEstimateBalances(
|
|
||||||
source: EstimateBalanceSnapshot[],
|
|
||||||
targetProjectId: string,
|
|
||||||
createId: () => string
|
|
||||||
): EstimateBalanceSnapshot[] {
|
|
||||||
return source.map((balance) =>
|
|
||||||
parseEstimateBalanceSnapshot({
|
|
||||||
...balance,
|
|
||||||
id: createId(),
|
|
||||||
projectId: targetProjectId,
|
|
||||||
groups: balance.groups.map((group) => ({
|
|
||||||
...group,
|
|
||||||
id: createId(),
|
|
||||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remapProjectState(
|
export function remapProjectState(
|
||||||
source: ProjectStateSnapshot,
|
source: ProjectStateSnapshot,
|
||||||
targetProjectId: string,
|
targetProjectId: string,
|
||||||
|
|
@ -131,7 +84,6 @@ export function remapProjectState(
|
||||||
createId
|
createId
|
||||||
);
|
);
|
||||||
const deviceIds = idMap(source.projectDevices, createId);
|
const deviceIds = idMap(source.projectDevices, createId);
|
||||||
const buildingIds = idMap(source.buildings, createId);
|
|
||||||
const floorIds = idMap(source.floors, createId);
|
const floorIds = idMap(source.floors, createId);
|
||||||
const roomIds = idMap(source.rooms, createId);
|
const roomIds = idMap(source.rooms, createId);
|
||||||
const rowIds = new Map(
|
const rowIds = new Map(
|
||||||
|
|
@ -290,19 +242,10 @@ export function remapProjectState(
|
||||||
id: requiredId(deviceIds, device.id),
|
id: requiredId(deviceIds, device.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
})),
|
})),
|
||||||
buildings: source.buildings.map((building) => ({
|
|
||||||
...building,
|
|
||||||
id: requiredId(buildingIds, building.id),
|
|
||||||
projectId: targetProjectId,
|
|
||||||
})),
|
|
||||||
floors: source.floors.map((floor) => ({
|
floors: source.floors.map((floor) => ({
|
||||||
...floor,
|
...floor,
|
||||||
id: requiredId(floorIds, floor.id),
|
id: requiredId(floorIds, floor.id),
|
||||||
projectId: targetProjectId,
|
projectId: targetProjectId,
|
||||||
buildingId:
|
|
||||||
floor.buildingId === null
|
|
||||||
? null
|
|
||||||
: requiredId(buildingIds, floor.buildingId),
|
|
||||||
})),
|
})),
|
||||||
rooms: source.rooms.map((room) => ({
|
rooms: source.rooms.map((room) => ({
|
||||||
...room,
|
...room,
|
||||||
|
|
@ -329,37 +272,6 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||||
return targetId;
|
return targetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTransferEstimateBalances(
|
|
||||||
value: unknown,
|
|
||||||
projectId: string
|
|
||||||
): EstimateBalanceSnapshot[] {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
throw new Error("Project transfer estimate balances must be an array.");
|
|
||||||
}
|
|
||||||
const balances = value.map(parseEstimateBalanceSnapshot);
|
|
||||||
if (balances.some((balance) => balance.projectId !== projectId)) {
|
|
||||||
throw new Error("Project transfer estimate balance belongs to another project.");
|
|
||||||
}
|
|
||||||
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
|
|
||||||
assertUniqueIds(
|
|
||||||
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
|
|
||||||
"estimate group"
|
|
||||||
);
|
|
||||||
assertUniqueIds(
|
|
||||||
balances.flatMap((balance) =>
|
|
||||||
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
|
|
||||||
),
|
|
||||||
"estimate row"
|
|
||||||
);
|
|
||||||
return balances;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertUniqueIds(ids: string[], label: string) {
|
|
||||||
if (new Set(ids).size !== ids.length) {
|
|
||||||
throw new Error(`Project transfer contains a duplicate ${label} id.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,18 +70,12 @@ import {
|
||||||
type SerializedProjectCommand,
|
type SerializedProjectCommand,
|
||||||
} from "../models/project-command.model.js";
|
} from "../models/project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertProjectBuildingDeleteProjectCommand,
|
|
||||||
assertProjectBuildingInsertProjectCommand,
|
|
||||||
assertProjectBuildingUpdateProjectCommand,
|
|
||||||
assertProjectFloorDeleteProjectCommand,
|
assertProjectFloorDeleteProjectCommand,
|
||||||
assertProjectFloorInsertProjectCommand,
|
assertProjectFloorInsertProjectCommand,
|
||||||
assertProjectFloorUpdateProjectCommand,
|
assertProjectFloorUpdateProjectCommand,
|
||||||
assertProjectRoomDeleteProjectCommand,
|
assertProjectRoomDeleteProjectCommand,
|
||||||
assertProjectRoomInsertProjectCommand,
|
assertProjectRoomInsertProjectCommand,
|
||||||
assertProjectRoomUpdateProjectCommand,
|
assertProjectRoomUpdateProjectCommand,
|
||||||
projectBuildingDeleteCommandType,
|
|
||||||
projectBuildingInsertCommandType,
|
|
||||||
projectBuildingUpdateCommandType,
|
|
||||||
projectFloorDeleteCommandType,
|
projectFloorDeleteCommandType,
|
||||||
projectFloorInsertCommandType,
|
projectFloorInsertCommandType,
|
||||||
projectFloorUpdateCommandType,
|
projectFloorUpdateCommandType,
|
||||||
|
|
@ -194,15 +188,6 @@ import {
|
||||||
externalObjectDeleteCreatedCircuitCommandType,
|
externalObjectDeleteCreatedCircuitCommandType,
|
||||||
} from "../models/external-object-new-circuit-project-command.model.js";
|
} from "../models/external-object-new-circuit-project-command.model.js";
|
||||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
|
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
|
||||||
import {
|
|
||||||
assertEstimateBalanceDeleteProjectCommand,
|
|
||||||
assertEstimateBalanceInsertProjectCommand,
|
|
||||||
assertEstimateBalanceUpdateProjectCommand,
|
|
||||||
estimateBalanceDeleteCommandType,
|
|
||||||
estimateBalanceInsertCommandType,
|
|
||||||
estimateBalanceUpdateCommandType,
|
|
||||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
|
||||||
import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
|
|
||||||
|
|
||||||
interface DispatchProjectCommandInput {
|
interface DispatchProjectCommandInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -242,8 +227,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
|
||||||
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
|
|
@ -508,18 +492,6 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
case projectBuildingInsertCommandType: {
|
|
||||||
assertProjectBuildingInsertProjectCommand(input.command);
|
|
||||||
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
|
|
||||||
}
|
|
||||||
case projectBuildingDeleteCommandType: {
|
|
||||||
assertProjectBuildingDeleteProjectCommand(input.command);
|
|
||||||
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
|
|
||||||
}
|
|
||||||
case projectBuildingUpdateCommandType: {
|
|
||||||
assertProjectBuildingUpdateProjectCommand(input.command);
|
|
||||||
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
|
|
||||||
}
|
|
||||||
case projectFloorInsertCommandType: {
|
case projectFloorInsertCommandType: {
|
||||||
assertProjectFloorInsertProjectCommand(input.command);
|
assertProjectFloorInsertProjectCommand(input.command);
|
||||||
return this.projectLocationStructureStore.execute({
|
return this.projectLocationStructureStore.execute({
|
||||||
|
|
@ -664,24 +636,6 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
case estimateBalanceInsertCommandType:
|
|
||||||
case estimateBalanceDeleteCommandType:
|
|
||||||
case estimateBalanceUpdateCommandType: {
|
|
||||||
if (input.command.type === estimateBalanceInsertCommandType) {
|
|
||||||
assertEstimateBalanceInsertProjectCommand(input.command);
|
|
||||||
} else if (input.command.type === estimateBalanceDeleteCommandType) {
|
|
||||||
assertEstimateBalanceDeleteProjectCommand(input.command);
|
|
||||||
} else {
|
|
||||||
assertEstimateBalanceUpdateProjectCommand(input.command);
|
|
||||||
}
|
|
||||||
if (!this.estimateBalanceStore) {
|
|
||||||
throw new Error("Estimate-balance store is not available.");
|
|
||||||
}
|
|
||||||
return this.estimateBalanceStore.execute({
|
|
||||||
...input,
|
|
||||||
command: input.command,
|
|
||||||
}).revision;
|
|
||||||
}
|
|
||||||
case projectStateRestoreCommandType: {
|
case projectStateRestoreCommandType: {
|
||||||
assertProjectStateRestoreCommand(input.command);
|
assertProjectStateRestoreCommand(input.command);
|
||||||
return this.projectStateRestoreStore.execute({
|
return this.projectStateRestoreStore.execute({
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js";
|
|
||||||
|
|
||||||
export const createEstimateBalanceRequestSchema = z
|
|
||||||
.object({
|
|
||||||
expectedRevision: expectedProjectRevisionSchema,
|
|
||||||
name: z.string().trim().min(1).max(200),
|
|
||||||
workStage: z.string().trim().max(50).nullable().optional(),
|
|
||||||
dateIso: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
template: z.enum(["kg400", "empty", "copy"]),
|
|
||||||
copyFromBalanceId: z.string().trim().min(1).optional(),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), {
|
|
||||||
message: "copyFromBalanceId is required for template copy",
|
|
||||||
path: ["copyFromBalanceId"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateEstimateBalanceRequestSchema = z
|
|
||||||
.object({
|
|
||||||
expectedRevision: expectedProjectRevisionSchema,
|
|
||||||
// Validated in detail by parseEstimateBalanceSnapshot.
|
|
||||||
balance: z.unknown(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const deleteEstimateBalanceRequestSchema = z
|
|
||||||
.object({ expectedRevision: expectedProjectRevisionSchema })
|
|
||||||
.strict();
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
import crypto from "node:crypto";
|
|
||||||
import { z } from "zod";
|
|
||||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
|
||||||
import {
|
|
||||||
estimateDefaultGroups,
|
|
||||||
type EstimateBalanceSnapshot,
|
|
||||||
type EstimateGroupSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
|
|
||||||
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
|
||||||
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
|
||||||
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
|
||||||
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
const idSchema = z.string().trim().min(1).max(100);
|
|
||||||
const trimmed = (max: number) =>
|
|
||||||
z.string().max(max).refine((value) => value === value.trim(), {
|
|
||||||
message: "must be trimmed",
|
|
||||||
});
|
|
||||||
const nullableText = (max: number) =>
|
|
||||||
trimmed(max)
|
|
||||||
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
|
||||||
.nullable();
|
|
||||||
const finite = z.number().finite();
|
|
||||||
|
|
||||||
export const estimateRowSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
sortOrder: z.number().int().min(0),
|
|
||||||
supply: nullableText(50),
|
|
||||||
name: trimmed(200),
|
|
||||||
quantity: finite.min(0),
|
|
||||||
unit: z.enum(["piece", "area"]),
|
|
||||||
connection: z.enum(["fixed", "socket"]).nullable(),
|
|
||||||
powerPerUnitW: finite.min(0),
|
|
||||||
simultaneityFactor: finite.min(0).max(1),
|
|
||||||
cosPhi: finite.gt(0).max(1),
|
|
||||||
safetySupply: z.boolean(),
|
|
||||||
remark: nullableText(500),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const estimateGroupSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
costGroup: trimmed(20),
|
|
||||||
title: trimmed(200).refine((value) => value.length > 0, {
|
|
||||||
message: "title must not be empty",
|
|
||||||
}),
|
|
||||||
editor: nullableText(100),
|
|
||||||
sortOrder: z.number().int().min(0),
|
|
||||||
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const estimateBalanceSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
name: trimmed(200).refine((value) => value.length > 0, {
|
|
||||||
message: "name must not be empty",
|
|
||||||
}),
|
|
||||||
workStage: nullableText(50),
|
|
||||||
dateIso: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
|
||||||
message: "invalid date",
|
|
||||||
})
|
|
||||||
.nullable(),
|
|
||||||
reserveAvPercent: finite.min(0).max(100),
|
|
||||||
reserveSvPercent: finite.min(0).max(100),
|
|
||||||
sortOrder: z.number().int().min(0),
|
|
||||||
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.superRefine((balance, context) => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const group of balance.groups) {
|
|
||||||
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
|
||||||
if (ids.has(id)) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `duplicate id ${id}`,
|
|
||||||
path: ["groups"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ids.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface EstimateBalanceInsertProjectCommand
|
|
||||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
|
||||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
||||||
type: typeof estimateBalanceInsertCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceDeleteProjectCommand
|
|
||||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
|
||||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
||||||
type: typeof estimateBalanceDeleteCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<{
|
|
||||||
expected: EstimateBalanceSnapshot;
|
|
||||||
target: EstimateBalanceSnapshot;
|
|
||||||
}> {
|
|
||||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
||||||
type: typeof estimateBalanceUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EstimateBalanceProjectCommand =
|
|
||||||
| EstimateBalanceInsertProjectCommand
|
|
||||||
| EstimateBalanceDeleteProjectCommand
|
|
||||||
| EstimateBalanceUpdateProjectCommand;
|
|
||||||
|
|
||||||
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
|
||||||
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
|
||||||
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
|
||||||
return normalizeEstimateBalanceSnapshot(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeEstimateBalanceSnapshot(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return {
|
|
||||||
...balance,
|
|
||||||
groups: balance.groups.map((group, groupIndex) => ({
|
|
||||||
...group,
|
|
||||||
sortOrder: groupIndex,
|
|
||||||
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable JSON for exact equality checks of two snapshots. */
|
|
||||||
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
|
||||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
|
||||||
return JSON.stringify({
|
|
||||||
id: normalized.id,
|
|
||||||
projectId: normalized.projectId,
|
|
||||||
name: normalized.name,
|
|
||||||
workStage: normalized.workStage,
|
|
||||||
dateIso: normalized.dateIso,
|
|
||||||
reserveAvPercent: normalized.reserveAvPercent,
|
|
||||||
reserveSvPercent: normalized.reserveSvPercent,
|
|
||||||
sortOrder: normalized.sortOrder,
|
|
||||||
groups: normalized.groups.map((group) => ({
|
|
||||||
id: group.id,
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: group.editor,
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
rows: group.rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
sortOrder: row.sortOrder,
|
|
||||||
supply: row.supply,
|
|
||||||
name: row.name,
|
|
||||||
quantity: row.quantity,
|
|
||||||
unit: row.unit,
|
|
||||||
connection: row.connection,
|
|
||||||
powerPerUnitW: row.powerPerUnitW,
|
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
|
||||||
cosPhi: row.cosPhi,
|
|
||||||
safetySupply: row.safetySupply,
|
|
||||||
remark: row.remark,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sameEstimateBalance(
|
|
||||||
left: EstimateBalanceSnapshot,
|
|
||||||
right: EstimateBalanceSnapshot
|
|
||||||
): boolean {
|
|
||||||
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceSnapshot(input: {
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
workStage: string | null;
|
|
||||||
dateIso: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
|
||||||
createId?: () => string;
|
|
||||||
}): EstimateBalanceSnapshot {
|
|
||||||
const createId = input.createId ?? (() => crypto.randomUUID());
|
|
||||||
let groups: EstimateGroupSnapshot[] = [];
|
|
||||||
let reserveAvPercent = 10;
|
|
||||||
let reserveSvPercent = 0;
|
|
||||||
if (input.template === "kg400") {
|
|
||||||
groups = estimateDefaultGroups.map((group, index) => ({
|
|
||||||
id: createId(),
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: null,
|
|
||||||
sortOrder: index,
|
|
||||||
rows: [],
|
|
||||||
}));
|
|
||||||
} else if (input.template !== "empty") {
|
|
||||||
const source = input.template.copyFrom;
|
|
||||||
reserveAvPercent = source.reserveAvPercent;
|
|
||||||
reserveSvPercent = source.reserveSvPercent;
|
|
||||||
groups = source.groups.map((group) => ({
|
|
||||||
...group,
|
|
||||||
id: createId(),
|
|
||||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return parseEstimateBalanceSnapshot({
|
|
||||||
id: createId(),
|
|
||||||
projectId: input.projectId,
|
|
||||||
name: input.name.trim(),
|
|
||||||
workStage: input.workStage?.trim() || null,
|
|
||||||
dateIso: input.dateIso || null,
|
|
||||||
reserveAvPercent,
|
|
||||||
reserveSvPercent,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceInsertProjectCommand(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceInsertProjectCommand {
|
|
||||||
return {
|
|
||||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
||||||
type: estimateBalanceInsertCommandType,
|
|
||||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceDeleteProjectCommand(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceDeleteProjectCommand {
|
|
||||||
return {
|
|
||||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
||||||
type: estimateBalanceDeleteCommandType,
|
|
||||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceUpdateProjectCommand(
|
|
||||||
expected: EstimateBalanceSnapshot,
|
|
||||||
target: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceUpdateProjectCommand {
|
|
||||||
const payload = {
|
|
||||||
expected: parseEstimateBalanceSnapshot(expected),
|
|
||||||
target: parseEstimateBalanceSnapshot(target),
|
|
||||||
};
|
|
||||||
assertMatchingUpdate(payload.expected, payload.target);
|
|
||||||
return {
|
|
||||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
||||||
type: estimateBalanceUpdateCommandType,
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertMatchingUpdate(
|
|
||||||
expected: EstimateBalanceSnapshot,
|
|
||||||
target: EstimateBalanceSnapshot
|
|
||||||
) {
|
|
||||||
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
|
||||||
throw new Error("Estimate-balance update must keep id and project.");
|
|
||||||
}
|
|
||||||
if (sameEstimateBalance(expected, target)) {
|
|
||||||
throw new Error("Estimate-balance update must change the balance.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertCommandShape(
|
|
||||||
command: SerializedProjectCommand<unknown>,
|
|
||||||
type: string,
|
|
||||||
keys: string[]
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const payload = command.payload;
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
|
||||||
command.type !== type ||
|
|
||||||
payload === null ||
|
|
||||||
typeof payload !== "object" ||
|
|
||||||
Array.isArray(payload) ||
|
|
||||||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported estimate-balance command.");
|
|
||||||
}
|
|
||||||
return payload as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertEstimateBalanceInsertProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is EstimateBalanceInsertProjectCommand {
|
|
||||||
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
|
||||||
parseEstimateBalanceSnapshot(payload.balance);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertEstimateBalanceDeleteProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is EstimateBalanceDeleteProjectCommand {
|
|
||||||
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
|
||||||
parseEstimateBalanceSnapshot(payload.balance);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertEstimateBalanceUpdateProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is EstimateBalanceUpdateProjectCommand {
|
|
||||||
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
|
||||||
"expected",
|
|
||||||
"target",
|
|
||||||
]);
|
|
||||||
assertMatchingUpdate(
|
|
||||||
parseEstimateBalanceSnapshot(payload.expected),
|
|
||||||
parseEstimateBalanceSnapshot(payload.target)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import type {
|
|
||||||
AppendedProjectRevision,
|
|
||||||
ProjectRevisionSource,
|
|
||||||
} from "../../domain/ports/project-revision.store.js";
|
|
||||||
import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js";
|
|
||||||
|
|
||||||
export interface ExecuteEstimateBalanceCommandInput {
|
|
||||||
projectId: string;
|
|
||||||
expectedRevision: number;
|
|
||||||
source: ProjectRevisionSource;
|
|
||||||
description?: string;
|
|
||||||
actorId?: string;
|
|
||||||
historyTargetChangeSetId?: string;
|
|
||||||
command: EstimateBalanceProjectCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecutedEstimateBalanceCommand {
|
|
||||||
revision: AppendedProjectRevision;
|
|
||||||
inverse: EstimateBalanceProjectCommand;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceProjectCommandStore {
|
|
||||||
execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand;
|
|
||||||
}
|
|
||||||
|
|
@ -4,14 +4,12 @@ import { useEffect, useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
// Every page shows the (collapsible) sidebar except the print view, which is
|
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
|
||||||
// a stand-alone document opened in its own tab and printed as PDF.
|
|
||||||
const PRINT_VIEW_PATTERN = /^\/projects\/[^/]+\/print(\/|$)/;
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
|
||||||
|
|
||||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const showSidebar = !PRINT_VIEW_PATTERN.test(pathname ?? "");
|
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,14 @@ import { listProjects } from "../utils/api";
|
||||||
import type { ProjectDto } from "../types";
|
import type { ProjectDto } from "../types";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
// Anchors on the project overview page (/projects/[id]).
|
|
||||||
const PROJECT_SECTIONS = [
|
const PROJECT_SECTIONS = [
|
||||||
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
||||||
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
||||||
|
{ id: "etagen", label: "Etagen", icon: "▥" },
|
||||||
|
{ id: "raeume", label: "Räume", icon: "⌂" },
|
||||||
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Separate project sub-routes (/projects/[id]/<segment>).
|
|
||||||
const PROJECT_ROUTES = [
|
|
||||||
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
|
||||||
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
|
||||||
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
|
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapsed: () => void;
|
onToggleCollapsed: () => void;
|
||||||
|
|
@ -30,14 +22,8 @@ interface SidebarProps {
|
||||||
|
|
||||||
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
|
||||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||||
const projectSubPath = projectMatch?.[2] ?? "";
|
|
||||||
// Section anchors only exist (and are observed) on the overview itself.
|
|
||||||
const overviewProjectId =
|
|
||||||
activeProjectId && (projectSubPath === "" || projectSubPath === "/")
|
|
||||||
? activeProjectId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||||
|
|
@ -56,7 +42,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!overviewProjectId) {
|
if (!activeProjectId) {
|
||||||
setActiveSection(null);
|
setActiveSection(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +69,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
|
|
||||||
elements.forEach((element) => observer.observe(element));
|
elements.forEach((element) => observer.observe(element));
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [overviewProjectId]);
|
}, [activeProjectId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
|
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
|
||||||
|
|
@ -136,46 +122,17 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
{activeProjectId && (
|
{activeProjectId && (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-section-label">In diesem Projekt</div>
|
<div className="sidebar-section-label">In diesem Projekt</div>
|
||||||
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
|
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
||||||
overviewProjectId ? (
|
<a
|
||||||
<a
|
key={id}
|
||||||
key={id}
|
href={`#${id}`}
|
||||||
href={`#${id}`}
|
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
title={label}
|
||||||
title={label}
|
>
|
||||||
>
|
<span className="sidebar-icon">{icon}</span>
|
||||||
<span className="sidebar-icon">{icon}</span>
|
<span className="sidebar-label">{label}</span>
|
||||||
<span className="sidebar-label">{label}</span>
|
</a>
|
||||||
</a>
|
))}
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
key={id}
|
|
||||||
href={`/projects/${activeProjectId}#${id}`}
|
|
||||||
className="sidebar-link"
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
<span className="sidebar-icon">{icon}</span>
|
|
||||||
<span className="sidebar-label">{label}</span>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{PROJECT_ROUTES.map(({ segment, label, icon }) => {
|
|
||||||
const href = `/projects/${activeProjectId}/${segment}`;
|
|
||||||
const isActive =
|
|
||||||
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={segment}
|
|
||||||
href={href}
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={isActive ? "sidebar-link active" : "sidebar-link"}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
<span className="sidebar-icon">{icon}</span>
|
|
||||||
<span className="sidebar-label">{label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import {
|
||||||
LAYING_METHODS,
|
LAYING_METHODS,
|
||||||
LAYING_METHOD_GROUP,
|
LAYING_METHOD_GROUP,
|
||||||
LAYING_METHOD_LABELS,
|
LAYING_METHOD_LABELS,
|
||||||
|
MAX_GROUPING_CIRCUITS,
|
||||||
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
|
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
|
||||||
REFERENCE_TEMPERATURE_C,
|
REFERENCE_TEMPERATURE_C,
|
||||||
maxGroupingCircuits,
|
|
||||||
temperatureOptions,
|
temperatureOptions,
|
||||||
type CableSizingAlert,
|
type CableSizingAlert,
|
||||||
type CableSizingInput,
|
type CableSizingInput,
|
||||||
|
|
@ -100,7 +100,6 @@ export function CableSizingModal({
|
||||||
? threePhaseVoltageV
|
? threePhaseVoltageV
|
||||||
: singlePhaseVoltageV;
|
: singlePhaseVoltageV;
|
||||||
const group = LAYING_METHOD_GROUP[layingMethod];
|
const group = LAYING_METHOD_GROUP[layingMethod];
|
||||||
const groupingMaximum = maxGroupingCircuits(layingMethod);
|
|
||||||
const availableTemperatures = temperatureOptions(layingMethod, insulation);
|
const availableTemperatures = temperatureOptions(layingMethod, insulation);
|
||||||
|
|
||||||
const input: CableSizingInput = useMemo(
|
const input: CableSizingInput = useMemo(
|
||||||
|
|
@ -209,7 +208,7 @@ export function CableSizingModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<FormModal
|
||||||
description="Querschnittsempfehlung mit Strombelastbarkeit, Temperatur- und Häufungsfaktoren nach DIN VDE 0298-4:2023-06 einschließlich Berichtigung 1:2023-10. Spannungsfall und Schutzorgan werden ergänzend geprüft. Die Empfehlung vor der Übernahme fachlich prüfen."
|
description="Querschnittsempfehlung nach IEC 60364-5-52 Anhang B (Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan) oder direkte Eingabe. Die Empfehlung vor der Übernahme fachlich prüfen; nationale Tabellen (DIN VDE 0298-4) können abweichen."
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|
@ -235,11 +234,6 @@ export function CableSizingModal({
|
||||||
type="number"
|
type="number"
|
||||||
value={lengthM}
|
value={lengthM}
|
||||||
/>
|
/>
|
||||||
{lengthM === 0 ? (
|
|
||||||
<div className="form-text text-warning">
|
|
||||||
Bei 0 m wird kein Spannungsfall angesetzt.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12 col-md-4">
|
<div className="col-12 col-md-4">
|
||||||
<label className="form-label" htmlFor="cable-sizing-phase">
|
<label className="form-label" htmlFor="cable-sizing-phase">
|
||||||
|
|
@ -285,7 +279,6 @@ export function CableSizingModal({
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const method = event.target.value as LayingMethod;
|
const method = event.target.value as LayingMethod;
|
||||||
setLayingMethod(method);
|
setLayingMethod(method);
|
||||||
setGroupingCircuits((current) => Math.min(current, maxGroupingCircuits(method)));
|
|
||||||
applyReferenceTemperature(method, insulation);
|
applyReferenceTemperature(method, insulation);
|
||||||
resetResult();
|
resetResult();
|
||||||
}}
|
}}
|
||||||
|
|
@ -369,7 +362,7 @@ export function CableSizingModal({
|
||||||
<input
|
<input
|
||||||
className="form-control"
|
className="form-control"
|
||||||
id="cable-sizing-grouping"
|
id="cable-sizing-grouping"
|
||||||
max={groupingMaximum}
|
max={MAX_GROUPING_CIRCUITS}
|
||||||
min={1}
|
min={1}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1)));
|
setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1)));
|
||||||
|
|
@ -380,10 +373,7 @@ export function CableSizingModal({
|
||||||
value={groupingCircuits}
|
value={groupingCircuits}
|
||||||
/>
|
/>
|
||||||
<div className="form-text">
|
<div className="form-text">
|
||||||
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses
|
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses.
|
||||||
{layingMethod === "E"
|
|
||||||
? "; ohne Angaben zur Kabelpritsche ist nur 1 zulässig."
|
|
||||||
: `; für diese Verlegeart sind bis ${groupingMaximum} tabelliert.`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{phase === 3 ? (
|
{phase === 3 ? (
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
ShieldPlus,
|
ShieldPlus,
|
||||||
TableProperties,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -101,7 +100,6 @@ import {
|
||||||
type MutableDistributionBoardComponentRole,
|
type MutableDistributionBoardComponentRole,
|
||||||
} from "../utils/distribution-board-component-editing";
|
} from "../utils/distribution-board-component-editing";
|
||||||
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
||||||
import { createClientId } from "../utils/client-id";
|
|
||||||
import type {
|
import type {
|
||||||
CellKey,
|
CellKey,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
|
@ -161,7 +159,6 @@ import type {
|
||||||
CircuitSnapshot,
|
CircuitSnapshot,
|
||||||
} from "../../domain/models/circuit-structure-project-command.model";
|
} from "../../domain/models/circuit-structure-project-command.model";
|
||||||
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
|
||||||
import { GzfPickerModal } from "./gzf-picker-modal";
|
|
||||||
import { CircuitGroupModal } from "./circuit-group-modal";
|
import { CircuitGroupModal } from "./circuit-group-modal";
|
||||||
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
import { CircuitProtectionModal } from "./circuit-protection-modal";
|
||||||
import { CableSizingModal } from "./cable-sizing-modal";
|
import { CableSizingModal } from "./cable-sizing-modal";
|
||||||
|
|
@ -280,7 +277,7 @@ function createValuesFromEditPatch(
|
||||||
* header heights stay uniform.
|
* header heights stay uniform.
|
||||||
*/
|
*/
|
||||||
function renderColumnHeaderLabel(label: string) {
|
function renderColumnHeaderLabel(label: string) {
|
||||||
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
|
const match = /^(.*?)s*([[^]]+])$/.exec(label);
|
||||||
const name = (match ? match[1] : label).replace(/-/g, "‑");
|
const name = (match ? match[1] : label).replace(/-/g, "‑");
|
||||||
return match ? (
|
return match ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -322,8 +319,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
useState<StructureComponentEditorIntent | null>(null);
|
useState<StructureComponentEditorIntent | null>(null);
|
||||||
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
|
||||||
useState<CircuitGroupEditorIntent | null>(null);
|
useState<CircuitGroupEditorIntent | null>(null);
|
||||||
// GZF cell the AMEV picker writes to; null while the picker is closed.
|
|
||||||
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
|
|
||||||
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
const [protectionEditorCircuit, setProtectionEditorCircuit] =
|
||||||
useState<CircuitTreeCircuitDto | null>(null);
|
useState<CircuitTreeCircuitDto | null>(null);
|
||||||
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
|
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
|
||||||
|
|
@ -1027,7 +1022,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
defaultSortOrder: number
|
defaultSortOrder: number
|
||||||
) {
|
) {
|
||||||
return buildCircuitDeviceRowInsertSnapshot({
|
return buildCircuitDeviceRowInsertSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitId,
|
circuitId,
|
||||||
values,
|
values,
|
||||||
defaultSortOrder,
|
defaultSortOrder,
|
||||||
|
|
@ -1051,7 +1046,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
const circuitId = createClientId();
|
const circuitId = crypto.randomUUID();
|
||||||
const deviceRows = deviceRowValues.map((row, index) =>
|
const deviceRows = deviceRowValues.map((row, index) =>
|
||||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||||
);
|
);
|
||||||
|
|
@ -1219,7 +1214,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
(section) => section.id === intent.sectionId
|
(section) => section.id === intent.sectionId
|
||||||
)?.components ?? [];
|
)?.components ?? [];
|
||||||
const snapshot = buildDistributionBoardComponentSnapshot({
|
const snapshot = buildDistributionBoardComponentSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
role: intent.role,
|
role: intent.role,
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
|
|
@ -1284,7 +1279,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
projectId,
|
projectId,
|
||||||
getExpectedProjectRevision(),
|
getExpectedProjectRevision(),
|
||||||
buildNewCircuitGroupSnapshot({
|
buildNewCircuitGroupSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
category: values.category,
|
category: values.category,
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
|
|
@ -1904,26 +1899,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commits the active editor input through command history so edits remain undoable.
|
// Commits the active editor input through command history so edits remain undoable.
|
||||||
// `target` commits a value for a cell without an open inline editor
|
|
||||||
// (AMEV GZF picker); the regular edit path passes no target.
|
|
||||||
async function commitEdit(
|
async function commitEdit(
|
||||||
direction: SaveDirection = "stay",
|
direction: SaveDirection = "stay",
|
||||||
draftOverride?: string,
|
draftOverride?: string
|
||||||
target?: SelectedCell
|
|
||||||
) {
|
) {
|
||||||
const session = target ?? editingCell;
|
if (!editingCell) {
|
||||||
if (!session) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const row = findRow(session.rowKey);
|
const row = findRow(editingCell.rowKey);
|
||||||
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
|
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
|
||||||
if (!row || !cell || !cell.editable) {
|
if (!row || !cell || !cell.editable) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const draft = draftOverride ?? editingCell?.draft ?? "";
|
const draft = draftOverride ?? editingCell.draft;
|
||||||
if (
|
if (
|
||||||
session.cellKey === "equipmentIdentifier" &&
|
editingCell.cellKey === "equipmentIdentifier" &&
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||||
) {
|
) {
|
||||||
|
|
@ -1931,7 +1922,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
|
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
|
||||||
const nextSelectionIntent = () => {
|
const nextSelectionIntent = () => {
|
||||||
let selected = targetSelection;
|
let selected = targetSelection;
|
||||||
if (direction !== "stay") {
|
if (direction !== "stay") {
|
||||||
|
|
@ -1954,7 +1945,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const selection = await createFromPlaceholder(
|
const selection = await createFromPlaceholder(
|
||||||
row.sectionId,
|
row.sectionId,
|
||||||
session.cellKey,
|
editingCell.cellKey,
|
||||||
draft
|
draft
|
||||||
);
|
);
|
||||||
targetSelection = selection;
|
targetSelection = selection;
|
||||||
|
|
@ -1971,7 +1962,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Stromkreisfeld bearbeiten",
|
label: "Stromkreisfeld bearbeiten",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await patchCircuit(row.circuit!.id, session.cellKey, next);
|
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -1985,7 +1976,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Gerätefeld bearbeiten",
|
label: "Gerätefeld bearbeiten",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await patchDeviceRow(row.device!.id, session.cellKey, next);
|
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -2000,7 +1991,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
label: "Gerätezeile im Reservestromkreis erstellen",
|
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const editValues = createValuesFromEditPatch(
|
const editValues = createValuesFromEditPatch(
|
||||||
buildDeviceRowEditPatch(session.cellKey, next)
|
buildDeviceRowEditPatch(editingCell.cellKey, next)
|
||||||
);
|
);
|
||||||
const snapshot = createDeviceRowSnapshot(
|
const snapshot = createDeviceRowSnapshot(
|
||||||
row.circuit!.id,
|
row.circuit!.id,
|
||||||
|
|
@ -2020,7 +2011,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
snapshot,
|
snapshot,
|
||||||
"Gerätezeile im Reservestromkreis erstellen"
|
"Gerätezeile im Reservestromkreis erstellen"
|
||||||
);
|
);
|
||||||
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
|
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -2029,45 +2020,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
|
|
||||||
return Boolean(
|
|
||||||
target &&
|
|
||||||
target.cellKey === "simultaneityFactor" &&
|
|
||||||
findCell(target.rowKey, target.cellKey)?.editable
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
|
|
||||||
// inline edit is discarded because the picked value replaces it.
|
|
||||||
function openGzfPicker(target: SelectedCell) {
|
|
||||||
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEditingCell(null);
|
|
||||||
setSelectedCell(target);
|
|
||||||
setGzfPickerCell(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeGzfPicker() {
|
|
||||||
setGzfPickerCell(null);
|
|
||||||
focusGridWithoutScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commits the picked mean value like a typed edit (undoable command).
|
|
||||||
function handleGzfPicked(value: number) {
|
|
||||||
const target = gzfPickerCell;
|
|
||||||
setGzfPickerCell(null);
|
|
||||||
if (!target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// parseNumeric accepts only the German decimal comma.
|
|
||||||
const draft = value.toLocaleString("de-DE", {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
useGrouping: false,
|
|
||||||
});
|
|
||||||
void commitEdit("stay", draft, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancels current edit session and restores grid keyboard focus.
|
// Cancels current edit session and restores grid keyboard focus.
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
if (!editingCell) {
|
if (!editingCell) {
|
||||||
|
|
@ -3011,14 +2963,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
// - printable key starts overwrite edit (type-to-edit)
|
// - printable key starts overwrite edit (type-to-edit)
|
||||||
// - Arrow/Tab move selectedCell when not editing
|
// - Arrow/Tab move selectedCell when not editing
|
||||||
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
// The picker modal sits outside the grid; keep grid keys from acting behind it.
|
|
||||||
if (gzfPickerCell) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
closeGzfPicker();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -3086,11 +3030,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
moveHorizontal(-1);
|
moveHorizontal(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
|
|
||||||
event.preventDefault();
|
|
||||||
openGzfPicker(selectedCell);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === "ArrowDown") {
|
if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveVertical(1);
|
moveVertical(1);
|
||||||
|
|
@ -3258,9 +3197,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
onSave={handleSaveCircuitGroup}
|
onSave={handleSaveCircuitGroup}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{gzfPickerCell ? (
|
|
||||||
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
|
|
||||||
) : null}
|
|
||||||
{protectionEditorCircuit ? (
|
{protectionEditorCircuit ? (
|
||||||
<CircuitProtectionModal
|
<CircuitProtectionModal
|
||||||
equipmentIdentifier={
|
equipmentIdentifier={
|
||||||
|
|
@ -4594,21 +4530,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
} else if (event.key === "Tab") {
|
} else if (event.key === "Tab") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void commitEdit(event.shiftKey ? "prev" : "next");
|
void commitEdit(event.shiftKey ? "prev" : "next");
|
||||||
} else if (
|
|
||||||
event.altKey &&
|
|
||||||
event.key === "ArrowDown" &&
|
|
||||||
column.key === "simultaneityFactor"
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const active = document.activeElement as HTMLElement | null;
|
const active = document.activeElement as HTMLElement | null;
|
||||||
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!active || !containerRef.current?.contains(active)) {
|
if (!active || !containerRef.current?.contains(active)) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
focusGridWithoutScroll();
|
focusGridWithoutScroll();
|
||||||
|
|
@ -4616,27 +4542,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : column.key === "simultaneityFactor" &&
|
|
||||||
cell.editable &&
|
|
||||||
isSelected ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
|
||||||
className="cell-picker-trigger"
|
|
||||||
disabled={isSaving || historyBusy}
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
|
|
||||||
}}
|
|
||||||
onDoubleClick={(event) => event.stopPropagation()}
|
|
||||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<TableProperties aria-hidden="true" size={13} />
|
|
||||||
</button>
|
|
||||||
{formatCellValue(cell.value, column.key)}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
formatCellValue(cell.value, column.key)
|
formatCellValue(cell.value, column.key)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
distributionBoardInfoFieldLabels,
|
|
||||||
distributionBoardInfoFieldMaxLengths,
|
|
||||||
distributionBoardInfoFields,
|
|
||||||
type DistributionBoardInfoField,
|
|
||||||
} from "../../shared/constants/distribution-board";
|
|
||||||
import type { DistributionBoardInfoDto } from "../types";
|
|
||||||
|
|
||||||
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
|
|
||||||
|
|
||||||
const placeholders: Record<DistributionBoardInfoField, string> = {
|
|
||||||
boardNumber: "z. B. UV 1.1",
|
|
||||||
location: "z. B. Raum 003 Technik",
|
|
||||||
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
|
|
||||||
remark: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function toDistributionBoardInfoDraft(
|
|
||||||
info: DistributionBoardInfoDto
|
|
||||||
): DistributionBoardInfoDraft {
|
|
||||||
return {
|
|
||||||
boardNumber: info.boardNumber ?? "",
|
|
||||||
location: info.location ?? "",
|
|
||||||
fedFrom: info.fedFrom ?? "",
|
|
||||||
remark: info.remark ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromDistributionBoardInfoDraft(
|
|
||||||
draft: DistributionBoardInfoDraft
|
|
||||||
): DistributionBoardInfoDto {
|
|
||||||
const value = (field: DistributionBoardInfoField) =>
|
|
||||||
draft[field].trim() ? draft[field].trim() : null;
|
|
||||||
return {
|
|
||||||
boardNumber: value("boardNumber"),
|
|
||||||
location: value("location"),
|
|
||||||
fedFrom: value("fedFrom"),
|
|
||||||
remark: value("remark"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Descriptive board data used by the print view title block. The values are
|
|
||||||
* local planning values; a later Revit import may prefill them only through
|
|
||||||
* an explicit, previewed decision.
|
|
||||||
*/
|
|
||||||
export function DistributionBoardInfoCard({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: DistributionBoardInfoDraft;
|
|
||||||
onChange: (draft: DistributionBoardInfoDraft) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-labelledby="distribution-board-info-heading"
|
|
||||||
className="card"
|
|
||||||
>
|
|
||||||
<div className="card-header" id="distribution-board-info-heading">
|
|
||||||
Infokarte
|
|
||||||
</div>
|
|
||||||
<div className="card-body row g-3">
|
|
||||||
{distributionBoardInfoFields.map((field) => (
|
|
||||||
<div
|
|
||||||
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
|
|
||||||
key={field}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="form-label"
|
|
||||||
htmlFor={`distribution-board-info-${field}`}
|
|
||||||
>
|
|
||||||
{distributionBoardInfoFieldLabels[field]}
|
|
||||||
</label>
|
|
||||||
{field === "remark" ? (
|
|
||||||
<textarea
|
|
||||||
className="form-control"
|
|
||||||
id={`distribution-board-info-${field}`}
|
|
||||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({ ...draft, [field]: event.target.value })
|
|
||||||
}
|
|
||||||
rows={2}
|
|
||||||
value={draft[field]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id={`distribution-board-info-${field}`}
|
|
||||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({ ...draft, [field]: event.target.value })
|
|
||||||
}
|
|
||||||
placeholder={placeholders[field]}
|
|
||||||
value={draft[field]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{field === "fedFrom" ? (
|
|
||||||
<div className="form-text">
|
|
||||||
Vorerst Freitext. Die logische Verknüpfung mit dem
|
|
||||||
speisenden Verteiler folgt.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
|
|
||||||
import React, { useEffect, useId, useState, type ReactNode } from "react";
|
|
||||||
import { IconButton } from "./icon-button";
|
|
||||||
|
|
||||||
function readCollapsed(storageKey: string) {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem(storageKey) === "1";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
|
||||||
} catch {
|
|
||||||
// Storage unavailable (private mode, blocked): keep the in-memory state.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collapsible filter area inside a card (PM app style): a small header row
|
|
||||||
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
|
|
||||||
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
|
|
||||||
* button while a filter is active. Only the bar collapses, never the card.
|
|
||||||
* The collapsed state is remembered per `storageKey` (default: expanded).
|
|
||||||
*/
|
|
||||||
export function CollapsibleFilterPanel({
|
|
||||||
storageKey,
|
|
||||||
isFilterActive,
|
|
||||||
visibleCount,
|
|
||||||
totalCount,
|
|
||||||
onReset,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
storageKey: string;
|
|
||||||
isFilterActive: boolean;
|
|
||||||
visibleCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
onReset: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
||||||
const barId = useId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setIsCollapsed(readCollapsed(storageKey));
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
function toggleCollapsed() {
|
|
||||||
const next = !isCollapsed;
|
|
||||||
setIsCollapsed(next);
|
|
||||||
writeCollapsed(storageKey, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="filter-panel">
|
|
||||||
<div className="filter-panel-header">
|
|
||||||
<IconButton
|
|
||||||
ariaControls={barId}
|
|
||||||
ariaExpanded={!isCollapsed}
|
|
||||||
className="filter-panel-toggle"
|
|
||||||
icon={isCollapsed ? ChevronRight : ChevronDown}
|
|
||||||
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
|
||||||
onClick={toggleCollapsed}
|
|
||||||
/>
|
|
||||||
<span className="filter-panel-label">Filter</span>
|
|
||||||
{isCollapsed && isFilterActive ? (
|
|
||||||
<span className="filter-panel-badge">Filter aktiv</span>
|
|
||||||
) : null}
|
|
||||||
<span aria-live="polite" className="filter-panel-count">
|
|
||||||
{visibleCount} von {totalCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isCollapsed ? null : (
|
|
||||||
<div className="filter-bar" id={barId}>
|
|
||||||
{children}
|
|
||||||
{isFilterActive ? (
|
|
||||||
<IconButton
|
|
||||||
className="filter-bar-reset"
|
|
||||||
icon={FilterX}
|
|
||||||
label="Zurücksetzen"
|
|
||||||
onClick={onReset}
|
|
||||||
showLabel
|
|
||||||
title="Alle Filter zurücksetzen"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Search field for a filter bar. */
|
|
||||||
export function FilterSearchField({
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
placeholder: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="filter-bar-field">
|
|
||||||
<span className="filter-bar-label">{label}</span>
|
|
||||||
<input
|
|
||||||
className="filter-bar-search"
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type="search"
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import type { BuildingDto, FloorDto } from "../types";
|
|
||||||
import { groupFloorsByBuilding } from "../utils/building-structure";
|
|
||||||
|
|
||||||
export type FloorOption = Pick<FloorDto, "id" | "name"> & {
|
|
||||||
buildingId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FloorSelectOptionsProps {
|
|
||||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
|
||||||
floors: ReadonlyArray<FloorOption>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Floor <option>s for a <select>, grouped by building via <optgroup> as soon
|
|
||||||
* as the project has buildings. The caller renders its own empty option
|
|
||||||
* ("Ohne Etage") before these.
|
|
||||||
*/
|
|
||||||
export function FloorSelectOptions({
|
|
||||||
buildings = [],
|
|
||||||
floors,
|
|
||||||
}: FloorSelectOptionsProps) {
|
|
||||||
if (!buildings.length) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{floors.map((floor) => (
|
|
||||||
<option key={floor.id} value={floor.id}>
|
|
||||||
{floor.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{groupFloorsByBuilding(floors, buildings).map((group) => (
|
|
||||||
<optgroup key={group.buildingId ?? "unassigned"} label={group.label}>
|
|
||||||
{group.floors.map((floor) => (
|
|
||||||
<option key={floor.id} value={floor.id}>
|
|
||||||
{floor.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useId, useRef } from "react";
|
|
||||||
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
|
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
|
||||||
|
|
||||||
interface GzfPickerModalProps {
|
interface GzfPickerModalProps {
|
||||||
|
|
@ -13,50 +12,14 @@ function formatFactor(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
|
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
|
||||||
const titleId = useId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Wait for the inline editor's blur callback before taking keyboard focus.
|
|
||||||
const frame = requestAnimationFrame(() => {
|
|
||||||
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
|
||||||
});
|
|
||||||
return () => cancelAnimationFrame(frame);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
|
||||||
aria-labelledby={titleId}
|
|
||||||
aria-modal="true"
|
|
||||||
className="modal fade show d-block"
|
|
||||||
ref={dialogRef}
|
|
||||||
role="dialog"
|
|
||||||
tabIndex={-1}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
onClose();
|
|
||||||
} else if (event.key === "Tab") {
|
|
||||||
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
|
|
||||||
const first = buttons?.[0];
|
|
||||||
const last = buttons?.[buttons.length - 1];
|
|
||||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) {
|
|
||||||
event.preventDefault();
|
|
||||||
last?.focus();
|
|
||||||
} else if (!event.shiftKey && document.activeElement === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="modal-dialog modal-lg modal-dialog-centered">
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
<div className="modal-content">
|
<div className="modal-content">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
|
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
|
||||||
<p className="text-secondary small mb-0">
|
<p className="text-secondary small mb-0">
|
||||||
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
|
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import React, { type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact editor button with a lucide icon. `label` is always the accessible
|
* Compact editor button with a lucide icon. `label` is always the accessible
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation counterpart of `IconButton`: a Next.js link with the same
|
|
||||||
* look. `label` is always the accessible name and, unless `title` is given,
|
|
||||||
* the tooltip; with `showLabel` it is also rendered next to the icon.
|
|
||||||
*/
|
|
||||||
export function IconLink({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
title,
|
|
||||||
href,
|
|
||||||
variant = "default",
|
|
||||||
showLabel = false,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
title?: string;
|
|
||||||
href: string;
|
|
||||||
variant?: "default" | "danger" | "primary";
|
|
||||||
showLabel?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
aria-label={showLabel ? undefined : label}
|
|
||||||
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
|
||||||
href={href}
|
|
||||||
title={title ?? label}
|
|
||||||
>
|
|
||||||
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
|
||||||
{showLabel ? <span>{label}</span> : null}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import React, { useEffect, useId, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
toggleFilterValue,
|
|
||||||
type FilterOption,
|
|
||||||
} from "../utils/overview-filters";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter dropdown with a checkbox list (PM app style). An empty selection
|
|
||||||
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
|
|
||||||
* click and Escape.
|
|
||||||
*/
|
|
||||||
export function MultiSelectDropdown({
|
|
||||||
label,
|
|
||||||
options,
|
|
||||||
selected,
|
|
||||||
onChange,
|
|
||||||
emptyLabel = "Keine Einträge vorhanden",
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
options: readonly FilterOption[];
|
|
||||||
selected: readonly string[];
|
|
||||||
onChange: (selected: string[]) => void;
|
|
||||||
emptyLabel?: string;
|
|
||||||
}) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const labelId = useId();
|
|
||||||
const valueId = useId();
|
|
||||||
const panelId = useId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
function handlePointerDown(event: MouseEvent) {
|
|
||||||
if (
|
|
||||||
containerRef.current &&
|
|
||||||
!containerRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setIsOpen(false);
|
|
||||||
toggleRef.current?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handlePointerDown);
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", handlePointerDown);
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const summary =
|
|
||||||
selected.length === 0
|
|
||||||
? "Alle"
|
|
||||||
: selected.length === 1
|
|
||||||
? options.find((option) => option.id === selected[0])?.label ??
|
|
||||||
"1 ausgewählt"
|
|
||||||
: `${selected.length} ausgewählt`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="filter-dropdown" ref={containerRef}>
|
|
||||||
<span className="filter-bar-label" id={labelId}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
aria-controls={panelId}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-labelledby={`${labelId} ${valueId}`}
|
|
||||||
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
|
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
|
||||||
ref={toggleRef}
|
|
||||||
title={summary}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span className="filter-dropdown-value" id={valueId}>
|
|
||||||
{summary}
|
|
||||||
</span>
|
|
||||||
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
{isOpen ? (
|
|
||||||
<div
|
|
||||||
aria-labelledby={labelId}
|
|
||||||
className="filter-dropdown-panel"
|
|
||||||
id={panelId}
|
|
||||||
role="group"
|
|
||||||
>
|
|
||||||
{options.length === 0 ? (
|
|
||||||
<div className="filter-dropdown-empty">{emptyLabel}</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<label className="filter-dropdown-option filter-dropdown-option-all">
|
|
||||||
<input
|
|
||||||
checked={selected.length === 0}
|
|
||||||
onChange={() => onChange([])}
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
Alle
|
|
||||||
</label>
|
|
||||||
{options.map((option) => (
|
|
||||||
<label className="filter-dropdown-option" key={option.id}>
|
|
||||||
<input
|
|
||||||
checked={selected.includes(option.id)}
|
|
||||||
onChange={() =>
|
|
||||||
onChange(toggleFilterValue(selected, option.id))
|
|
||||||
}
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
{option.label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,16 +7,9 @@ import {
|
||||||
distributionBoardSupplyTypes,
|
distributionBoardSupplyTypes,
|
||||||
type DistributionBoardSupplyType,
|
type DistributionBoardSupplyType,
|
||||||
} from "../../shared/constants/distribution-board";
|
} from "../../shared/constants/distribution-board";
|
||||||
import {
|
|
||||||
projectCoverFieldLabels,
|
|
||||||
projectCoverFieldMaxLengths,
|
|
||||||
projectCoverFields,
|
|
||||||
type ProjectCoverField,
|
|
||||||
} from "../../shared/constants/project-cover";
|
|
||||||
import { FormModal } from "./form-modal";
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
export interface ProjectSettingsInput
|
export interface ProjectSettingsInput {
|
||||||
extends Record<ProjectCoverField, string | null> {
|
|
||||||
name: string;
|
name: string;
|
||||||
internalProjectNumber: string | null;
|
internalProjectNumber: string | null;
|
||||||
externalProjectNumber: string | null;
|
externalProjectNumber: string | null;
|
||||||
|
|
@ -28,13 +21,6 @@ export interface ProjectSettingsInput
|
||||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
|
|
||||||
siteAddress: "Straße Nr.\nPLZ Ort",
|
|
||||||
workStage: "z. B. LPH 5 – Ausführungsplanung",
|
|
||||||
projectLead: "",
|
|
||||||
projectEditor: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProjectSettingsModalProps {
|
interface ProjectSettingsModalProps {
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -85,14 +71,6 @@ export function ProjectSettingsModal({
|
||||||
] = useState<DistributionBoardSupplyType[]>(
|
] = useState<DistributionBoardSupplyType[]>(
|
||||||
project.enabledDistributionBoardSupplyTypes
|
project.enabledDistributionBoardSupplyTypes
|
||||||
);
|
);
|
||||||
const [coverValues, setCoverValues] = useState<
|
|
||||||
Record<ProjectCoverField, string>
|
|
||||||
>({
|
|
||||||
siteAddress: project.siteAddress ?? "",
|
|
||||||
workStage: project.workStage ?? "",
|
|
||||||
projectLead: project.projectLead ?? "",
|
|
||||||
projectEditor: project.projectEditor ?? "",
|
|
||||||
});
|
|
||||||
const [transfer, setTransfer] = useState<unknown>(null);
|
const [transfer, setTransfer] = useState<unknown>(null);
|
||||||
const [transferFilename, setTransferFilename] = useState("");
|
const [transferFilename, setTransferFilename] = useState("");
|
||||||
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||||
|
|
@ -113,10 +91,6 @@ export function ProjectSettingsModal({
|
||||||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||||
enabledDistributionBoardSupplyTypes,
|
enabledDistributionBoardSupplyTypes,
|
||||||
siteAddress: toNullableString(coverValues.siteAddress),
|
|
||||||
workStage: toNullableString(coverValues.workStage),
|
|
||||||
projectLead: toNullableString(coverValues.projectLead),
|
|
||||||
projectEditor: toNullableString(coverValues.projectEditor),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,57 +309,6 @@ export function ProjectSettingsModal({
|
||||||
value={threePhaseVoltageV}
|
value={threePhaseVoltageV}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-12">
|
|
||||||
<hr className="my-2" />
|
|
||||||
<h3 className="h6">Deckblatt</h3>
|
|
||||||
<p className="text-secondary small">
|
|
||||||
Angaben für das Deckblatt der Seitenansicht. Büroname und
|
|
||||||
Büroanschrift werden projektübergreifend in der Seitenansicht
|
|
||||||
gepflegt.
|
|
||||||
</p>
|
|
||||||
<div className="row g-3">
|
|
||||||
{projectCoverFields.map((field) => (
|
|
||||||
<div
|
|
||||||
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
|
|
||||||
key={field}
|
|
||||||
>
|
|
||||||
<label className="form-label" htmlFor={`project-cover-${field}`}>
|
|
||||||
{projectCoverFieldLabels[field]}
|
|
||||||
</label>
|
|
||||||
{field === "siteAddress" ? (
|
|
||||||
<textarea
|
|
||||||
className="form-control"
|
|
||||||
id={`project-cover-${field}`}
|
|
||||||
maxLength={projectCoverFieldMaxLengths[field]}
|
|
||||||
onChange={(event) =>
|
|
||||||
setCoverValues((current) => ({
|
|
||||||
...current,
|
|
||||||
[field]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={coverFieldPlaceholders[field]}
|
|
||||||
rows={2}
|
|
||||||
value={coverValues[field]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id={`project-cover-${field}`}
|
|
||||||
maxLength={projectCoverFieldMaxLengths[field]}
|
|
||||||
onChange={(event) =>
|
|
||||||
setCoverValues((current) => ({
|
|
||||||
...current,
|
|
||||||
[field]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={coverFieldPlaceholders[field]}
|
|
||||||
value={coverValues[field]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<hr className="my-2" />
|
<hr className="my-2" />
|
||||||
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,6 @@ import {
|
||||||
getProjectSnapshotRevisionDescription,
|
getProjectSnapshotRevisionDescription,
|
||||||
mergeProjectRevisionPages,
|
mergeProjectRevisionPages,
|
||||||
} from "../utils/project-version-history";
|
} from "../utils/project-version-history";
|
||||||
import {
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
Redo2,
|
|
||||||
RotateCcw,
|
|
||||||
Undo2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { IconButton, IconButtonSeparator } from "./icon-button";
|
|
||||||
|
|
||||||
interface ProjectVersionHistoryProps {
|
interface ProjectVersionHistoryProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -242,50 +234,50 @@ export function ProjectVersionHistory({
|
||||||
<span className="badge text-bg-secondary">
|
<span className="badge text-bg-secondary">
|
||||||
Aktuelle Revision {currentRevision}
|
Aktuelle Revision {currentRevision}
|
||||||
</span>
|
</span>
|
||||||
<div className="overview-actions">
|
<button
|
||||||
<IconButton
|
className="btn btn-sm btn-outline-secondary"
|
||||||
disabled={
|
disabled={
|
||||||
isBusy ||
|
isBusy ||
|
||||||
isHistoryLoading ||
|
isHistoryLoading ||
|
||||||
!historyIsCurrent ||
|
!historyIsCurrent ||
|
||||||
!history?.undoDepth
|
!history?.undoDepth
|
||||||
}
|
}
|
||||||
icon={Undo2}
|
onClick={() => void handleHistoryCommand("undo")}
|
||||||
label="Rückgängig"
|
title={
|
||||||
onClick={() => void handleHistoryCommand("undo")}
|
history?.undoDepth
|
||||||
showLabel
|
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||||
title={
|
: "Keine Änderung zum Rückgängigmachen"
|
||||||
history?.undoDepth
|
}
|
||||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
type="button"
|
||||||
: "Keine Änderung zum Rückgängigmachen"
|
>
|
||||||
}
|
Rückgängig
|
||||||
/>
|
</button>
|
||||||
<IconButton
|
<button
|
||||||
disabled={
|
className="btn btn-sm btn-outline-secondary"
|
||||||
isBusy ||
|
disabled={
|
||||||
isHistoryLoading ||
|
isBusy ||
|
||||||
!historyIsCurrent ||
|
isHistoryLoading ||
|
||||||
!history?.redoDepth
|
!historyIsCurrent ||
|
||||||
}
|
!history?.redoDepth
|
||||||
icon={Redo2}
|
}
|
||||||
label="Wiederholen"
|
onClick={() => void handleHistoryCommand("redo")}
|
||||||
onClick={() => void handleHistoryCommand("redo")}
|
title={
|
||||||
showLabel
|
history?.redoDepth
|
||||||
title={
|
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||||
history?.redoDepth
|
: "Keine Änderung zum Wiederholen"
|
||||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
}
|
||||||
: "Keine Änderung zum Wiederholen"
|
type="button"
|
||||||
}
|
>
|
||||||
/>
|
Wiederholen
|
||||||
<IconButtonSeparator />
|
</button>
|
||||||
<IconButton
|
<button
|
||||||
ariaExpanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
icon={isOpen ? ChevronUp : ChevronDown}
|
className="btn btn-sm btn-outline-secondary"
|
||||||
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
type="button"
|
||||||
showLabel
|
>
|
||||||
/>
|
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -389,15 +381,14 @@ export function ProjectVersionHistory({
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<div className="overview-actions justify-content-end">
|
<button
|
||||||
<IconButton
|
className="btn btn-sm btn-outline-danger"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
icon={RotateCcw}
|
onClick={() => setRestoreCandidate(snapshot)}
|
||||||
label="Wiederherstellen"
|
type="button"
|
||||||
onClick={() => setRestoreCandidate(snapshot)}
|
>
|
||||||
variant="danger"
|
Wiederherstellen
|
||||||
/>
|
</button>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { type FormEvent, useState } from "react";
|
import React, { type FormEvent, useState } from "react";
|
||||||
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
|
||||||
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
|
||||||
import { FormModal } from "./form-modal";
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
interface RoomModalProps {
|
interface RoomModalProps {
|
||||||
/** When given, the floor select groups floors by building. */
|
floors: FloorDto[];
|
||||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
|
||||||
floors: ReadonlyArray<FloorOption>;
|
|
||||||
initialRoom?: RoomDto;
|
initialRoom?: RoomDto;
|
||||||
initialValues?: Partial<CreateRoomInput>;
|
initialValues?: Partial<CreateRoomInput>;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
|
@ -18,7 +15,6 @@ interface RoomModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoomModal({
|
export function RoomModal({
|
||||||
buildings,
|
|
||||||
floors,
|
floors,
|
||||||
initialRoom,
|
initialRoom,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
|
@ -87,7 +83,7 @@ export function RoomModal({
|
||||||
value={floorId}
|
value={floorId}
|
||||||
>
|
>
|
||||||
<option value="">Ohne Etage</option>
|
<option value="">Ohne Etage</option>
|
||||||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
{floors.map((floor) => <option key={floor.id} value={floor.id}>{floor.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { type FormEvent, useState } from "react";
|
|
||||||
import { FormModal } from "../components/form-modal";
|
|
||||||
import type {
|
|
||||||
CreateEstimateBalanceInput,
|
|
||||||
EstimateBalanceDto,
|
|
||||||
EstimateBalanceTemplate,
|
|
||||||
} from "../types";
|
|
||||||
import { estimateTextLimits } from "./estimate-balance-document";
|
|
||||||
import { getTodayIsoDate } from "./estimate-balance-format";
|
|
||||||
|
|
||||||
export interface EstimateBalanceCreateDefaults {
|
|
||||||
name?: string;
|
|
||||||
workStage?: string | null;
|
|
||||||
template?: EstimateBalanceTemplate;
|
|
||||||
copyFromBalanceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EstimateBalanceCreateModalProps {
|
|
||||||
balances: EstimateBalanceDto[];
|
|
||||||
defaults: EstimateBalanceCreateDefaults;
|
|
||||||
isSaving: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onCreate: (input: CreateEstimateBalanceInput) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Neuer Stand": name, LPH, date and the starting content. */
|
|
||||||
export function EstimateBalanceCreateModal({
|
|
||||||
balances,
|
|
||||||
defaults,
|
|
||||||
isSaving,
|
|
||||||
onClose,
|
|
||||||
onCreate,
|
|
||||||
}: EstimateBalanceCreateModalProps) {
|
|
||||||
const [name, setName] = useState(defaults.name ?? "");
|
|
||||||
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
|
|
||||||
const [dateIso, setDateIso] = useState(getTodayIsoDate());
|
|
||||||
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
|
|
||||||
defaults.template ?? "kg400"
|
|
||||||
);
|
|
||||||
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
|
|
||||||
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
|
|
||||||
);
|
|
||||||
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!name.trim() || isCopyInvalid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onCreate({
|
|
||||||
name: name.trim(),
|
|
||||||
workStage: workStage.trim() || null,
|
|
||||||
dateIso: dateIso || null,
|
|
||||||
template,
|
|
||||||
...(template === "copy" ? { copyFromBalanceId } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormModal
|
|
||||||
isSaving={isSaving}
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
submitDisabled={!name.trim() || isCopyInvalid}
|
|
||||||
submitLabel="Stand anlegen"
|
|
||||||
title="Neuer Stand"
|
|
||||||
>
|
|
||||||
<div className="row g-3">
|
|
||||||
<div className="col-12 col-md-6">
|
|
||||||
<label className="form-label" htmlFor="estimate-create-name">
|
|
||||||
Bezeichnung
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-create-name"
|
|
||||||
maxLength={estimateTextLimits.balanceName}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
placeholder="z. B. Entwurfsplanung"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-3">
|
|
||||||
<label className="form-label" htmlFor="estimate-create-work-stage">
|
|
||||||
LPH
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-create-work-stage"
|
|
||||||
maxLength={estimateTextLimits.workStage}
|
|
||||||
onChange={(event) => setWorkStage(event.target.value)}
|
|
||||||
placeholder="z. B. LPH 3"
|
|
||||||
value={workStage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-6 col-md-3">
|
|
||||||
<label className="form-label" htmlFor="estimate-create-date">
|
|
||||||
Datum
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-create-date"
|
|
||||||
onChange={(event) => setDateIso(event.target.value)}
|
|
||||||
type="date"
|
|
||||||
value={dateIso}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-6">
|
|
||||||
<label className="form-label" htmlFor="estimate-create-template">
|
|
||||||
Inhalt
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
id="estimate-create-template"
|
|
||||||
onChange={(event) =>
|
|
||||||
setTemplate(event.target.value as EstimateBalanceTemplate)
|
|
||||||
}
|
|
||||||
value={template}
|
|
||||||
>
|
|
||||||
<option value="kg400">Vorlage KG 400</option>
|
|
||||||
<option value="empty">Leer</option>
|
|
||||||
<option disabled={!balances.length} value="copy">
|
|
||||||
Kopie von …
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{template === "copy" ? (
|
|
||||||
<div className="col-12 col-md-6">
|
|
||||||
<label className="form-label" htmlFor="estimate-create-copy-from">
|
|
||||||
Kopie von Stand
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
id="estimate-create-copy-from"
|
|
||||||
onChange={(event) => setCopyFromBalanceId(event.target.value)}
|
|
||||||
required
|
|
||||||
value={copyFromBalanceId}
|
|
||||||
>
|
|
||||||
{balances.map((balance) => (
|
|
||||||
<option key={balance.id} value={balance.id}>
|
|
||||||
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="col-12">
|
|
||||||
<p className="text-secondary small mb-0">
|
|
||||||
{template === "kg400"
|
|
||||||
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
|
|
||||||
: template === "empty"
|
|
||||||
? "Legt einen Stand ohne Kostengruppen an."
|
|
||||||
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
|
||||||
// The editor keeps the complete document as local draft and saves it as a
|
|
||||||
// whole (PUT), so every edit is a function draft → new draft.
|
|
||||||
import type {
|
|
||||||
EstimateBalanceSnapshot,
|
|
||||||
EstimateGroupSnapshot,
|
|
||||||
EstimateRowSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import { createClientId } from "../utils/client-id";
|
|
||||||
|
|
||||||
export type MoveDirection = -1 | 1;
|
|
||||||
|
|
||||||
/** Maximum text lengths accepted by the server. */
|
|
||||||
export const estimateTextLimits = {
|
|
||||||
balanceName: 200,
|
|
||||||
workStage: 50,
|
|
||||||
costGroup: 20,
|
|
||||||
groupTitle: 200,
|
|
||||||
groupEditor: 100,
|
|
||||||
supply: 50,
|
|
||||||
rowName: 200,
|
|
||||||
remark: 500,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
|
||||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
|
||||||
|
|
||||||
export function createEstimateRow(
|
|
||||||
sortOrder: number,
|
|
||||||
createId: () => string = createClientId
|
|
||||||
): EstimateRowSnapshot {
|
|
||||||
return {
|
|
||||||
id: createId(),
|
|
||||||
sortOrder,
|
|
||||||
supply: null,
|
|
||||||
name: "",
|
|
||||||
quantity: 1,
|
|
||||||
unit: "piece",
|
|
||||||
connection: null,
|
|
||||||
powerPerUnitW: 0,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: 1,
|
|
||||||
safetySupply: false,
|
|
||||||
remark: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateGroup(
|
|
||||||
input: { costGroup: string; title: string; editor?: string | null },
|
|
||||||
sortOrder: number,
|
|
||||||
createId: () => string = createClientId
|
|
||||||
): EstimateGroupSnapshot {
|
|
||||||
return {
|
|
||||||
id: createId(),
|
|
||||||
costGroup: input.costGroup,
|
|
||||||
title: input.title,
|
|
||||||
editor: input.editor ?? null,
|
|
||||||
sortOrder,
|
|
||||||
rows: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
|
||||||
return left.sortOrder - right.sortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
|
||||||
export function normalizeEstimateSortOrder(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return {
|
|
||||||
...balance,
|
|
||||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
|
||||||
...group,
|
|
||||||
sortOrder: groupIndex,
|
|
||||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
|
||||||
...row,
|
|
||||||
sortOrder: rowIndex,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
|
||||||
return items.map((item, index) =>
|
|
||||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
let changed = false;
|
|
||||||
const groups = balance.groups.map((group) => {
|
|
||||||
if (group.id !== groupId) {
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
const next = update(group);
|
|
||||||
changed = next !== group;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// An unknown group or a no-op keeps the identical document.
|
|
||||||
return changed ? { ...balance, groups } : balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
|
||||||
const target = index + direction;
|
|
||||||
if (index < 0 || target < 0 || target >= items.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const next = [...items];
|
|
||||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
|
||||||
export function addEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
options: { afterRowId?: string; createId?: () => string } = {}
|
|
||||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
|
||||||
let rowId: string | null = null;
|
|
||||||
const next = mapGroup(balance, groupId, (group) => {
|
|
||||||
const row = createEstimateRow(0, options.createId);
|
|
||||||
rowId = row.id;
|
|
||||||
const afterIndex = options.afterRowId
|
|
||||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
|
||||||
: -1;
|
|
||||||
const rows = [...group.rows];
|
|
||||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
|
||||||
return { ...group, rows: renumber(rows) };
|
|
||||||
});
|
|
||||||
return { balance: next, rowId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
rowId: string,
|
|
||||||
patch: EstimateRowPatch
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => ({
|
|
||||||
...group,
|
|
||||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
rowId: string
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => ({
|
|
||||||
...group,
|
|
||||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
rowId: string,
|
|
||||||
direction: MoveDirection
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => {
|
|
||||||
const rows = moveItem(
|
|
||||||
group.rows,
|
|
||||||
group.rows.findIndex((row) => row.id === rowId),
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends a group and returns its id. */
|
|
||||||
export function addEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
input: { costGroup: string; title: string; editor?: string | null },
|
|
||||||
createId: () => string = createClientId
|
|
||||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
|
||||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
|
||||||
return {
|
|
||||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
|
||||||
groupId: group.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
patch: EstimateGroupPatch
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
|
||||||
return groups.length === balance.groups.length
|
|
||||||
? balance
|
|
||||||
: { ...balance, groups: renumber(groups) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
direction: MoveDirection
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
const groups = moveItem(
|
|
||||||
balance.groups,
|
|
||||||
balance.groups.findIndex((group) => group.id === groupId),
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredText(value: string) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalText(value: string | null) {
|
|
||||||
const trimmed = value?.trim() ?? "";
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The exact document the server accepts: strings trimmed, empty optional
|
|
||||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
|
||||||
* type are copied, so no UI state can leak into the request.
|
|
||||||
*/
|
|
||||||
export function toSaveableEstimateBalance(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
const normalized = normalizeEstimateSortOrder(balance);
|
|
||||||
return {
|
|
||||||
id: normalized.id,
|
|
||||||
projectId: normalized.projectId,
|
|
||||||
name: requiredText(normalized.name),
|
|
||||||
workStage: optionalText(normalized.workStage),
|
|
||||||
dateIso: normalized.dateIso || null,
|
|
||||||
reserveAvPercent: normalized.reserveAvPercent,
|
|
||||||
reserveSvPercent: normalized.reserveSvPercent,
|
|
||||||
sortOrder: normalized.sortOrder,
|
|
||||||
groups: normalized.groups.map((group) => ({
|
|
||||||
id: group.id,
|
|
||||||
costGroup: requiredText(group.costGroup),
|
|
||||||
title: requiredText(group.title),
|
|
||||||
editor: optionalText(group.editor),
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
rows: group.rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
sortOrder: row.sortOrder,
|
|
||||||
supply: optionalText(row.supply),
|
|
||||||
name: requiredText(row.name),
|
|
||||||
quantity: row.quantity,
|
|
||||||
unit: row.unit,
|
|
||||||
connection: row.connection,
|
|
||||||
powerPerUnitW: row.powerPerUnitW,
|
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
|
||||||
cosPhi: row.cosPhi,
|
|
||||||
safetySupply: row.safetySupply,
|
|
||||||
remark: optionalText(row.remark),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable comparison key of the saveable document (dirty detection). */
|
|
||||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
|
||||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* German problems that would make the server reject the document. The
|
|
||||||
* editor never saves while this list is not empty.
|
|
||||||
*/
|
|
||||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
|
||||||
const problems: string[] = [];
|
|
||||||
const saveable = toSaveableEstimateBalance(balance);
|
|
||||||
if (!saveable.name) {
|
|
||||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
for (const group of saveable.groups) {
|
|
||||||
if (!group.title) {
|
|
||||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCopyName(name: string) {
|
|
||||||
const copyName = `${name.trim()} (Kopie)`;
|
|
||||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
|
|
||||||
// Numbers are entered and shown in German notation. As in the circuit list
|
|
||||||
// editor, only "," is accepted as decimal separator: a "." is ambiguous
|
|
||||||
// (decimal point or thousands separator) and is rejected instead of guessed.
|
|
||||||
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
|
|
||||||
export type DecimalParseResult =
|
|
||||||
| { ok: true; value: number }
|
|
||||||
| { ok: false; error: string };
|
|
||||||
|
|
||||||
export interface DecimalRange {
|
|
||||||
min?: number;
|
|
||||||
/** Excludes `min` itself (e.g. cos φ > 0). */
|
|
||||||
minExclusive?: boolean;
|
|
||||||
max?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
|
|
||||||
|
|
||||||
function formatBound(value: number) {
|
|
||||||
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return { ok: false, error: "Bitte einen Wert eingeben." };
|
|
||||||
}
|
|
||||||
if (trimmed.includes(".")) {
|
|
||||||
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
|
|
||||||
}
|
|
||||||
if (!DECIMAL_PATTERN.test(trimmed)) {
|
|
||||||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
|
||||||
}
|
|
||||||
const value = Number(trimmed.replace(",", "."));
|
|
||||||
if (!Number.isFinite(value)) {
|
|
||||||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
|
||||||
}
|
|
||||||
if (range.min !== undefined) {
|
|
||||||
if (range.minExclusive ? value <= range.min : value < range.min) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: range.minExclusive
|
|
||||||
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
|
|
||||||
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (range.max !== undefined && value > range.max) {
|
|
||||||
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
|
|
||||||
}
|
|
||||||
return { ok: true, value };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Value ranges accepted by the server for the numeric document fields. */
|
|
||||||
export const estimateNumberRanges = {
|
|
||||||
quantity: { min: 0 },
|
|
||||||
powerPerUnitW: { min: 0 },
|
|
||||||
simultaneityFactor: { min: 0, max: 1 },
|
|
||||||
cosPhi: { min: 0, minExclusive: true, max: 1 },
|
|
||||||
reservePercent: { min: 0, max: 100 },
|
|
||||||
} satisfies Record<string, DecimalRange>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Text of a numeric input field: German comma, no thousands separator, so the
|
|
||||||
* shown text can be parsed again unchanged.
|
|
||||||
*/
|
|
||||||
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
|
|
||||||
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-only display of a calculated value with thousands separator ("1.200",
|
|
||||||
* "30,24"). The value is rounded half away from zero first, like Excel does
|
|
||||||
* (40,175 → 40,18 instead of the binary-rounded 40,17).
|
|
||||||
*/
|
|
||||||
export function formatGermanNumber(
|
|
||||||
value: number,
|
|
||||||
minimumFractionDigits: number,
|
|
||||||
maximumFractionDigits = minimumFractionDigits
|
|
||||||
): string {
|
|
||||||
const rounded = roundEstimateValue(value, maximumFractionDigits);
|
|
||||||
// "+ 0" turns -0 into 0 and avoids "-0,00".
|
|
||||||
return (rounded + 0).toLocaleString("de-DE", {
|
|
||||||
minimumFractionDigits,
|
|
||||||
maximumFractionDigits,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatKw(value: number) {
|
|
||||||
return formatGermanNumber(value, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAmpere(value: number) {
|
|
||||||
return formatGermanNumber(value, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatSignedKw(value: number) {
|
|
||||||
const text = formatKw(value);
|
|
||||||
return value > 0 && text !== "0,00" ? `+${text}` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDeltaPercent(value: number | null) {
|
|
||||||
if (value === null) {
|
|
||||||
return "–";
|
|
||||||
}
|
|
||||||
const text = formatGermanNumber(value, 1);
|
|
||||||
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
|
|
||||||
export function formatIsoDateGerman(dateIso: string | null): string {
|
|
||||||
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
||||||
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
|
|
||||||
export function getTodayIsoDate(now: Date = new Date()): string {
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
return `${now.getFullYear()}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,669 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
|
||||||
import {
|
|
||||||
calculateEstimateBalance,
|
|
||||||
calculateEstimateRow,
|
|
||||||
estimateRowConnectionLabels,
|
|
||||||
estimateRowUnitLabels,
|
|
||||||
type EstimateBalanceSnapshot,
|
|
||||||
type EstimateGroupSnapshot,
|
|
||||||
type EstimatePower,
|
|
||||||
type EstimateRowConnection,
|
|
||||||
type EstimateRowSnapshot,
|
|
||||||
type EstimateRowUnit,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import {
|
|
||||||
addEstimateRow,
|
|
||||||
estimateTextLimits,
|
|
||||||
moveEstimateGroup,
|
|
||||||
removeEstimateGroup,
|
|
||||||
removeEstimateRow,
|
|
||||||
updateEstimateGroup,
|
|
||||||
updateEstimateRow,
|
|
||||||
type EstimateRowPatch,
|
|
||||||
} from "./estimate-balance-document";
|
|
||||||
import {
|
|
||||||
estimateNumberRanges,
|
|
||||||
formatAmpere,
|
|
||||||
formatDecimalInput,
|
|
||||||
formatKw,
|
|
||||||
parseGermanDecimal,
|
|
||||||
type DecimalRange,
|
|
||||||
formatGermanNumber,
|
|
||||||
} from "./estimate-balance-format";
|
|
||||||
import { DraftInput } from "./estimate-draft-input";
|
|
||||||
|
|
||||||
export type EstimateBalanceUpdate = (
|
|
||||||
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
interface EstimateBalanceSheetProps {
|
|
||||||
balance: EstimateBalanceSnapshot;
|
|
||||||
disabled: boolean;
|
|
||||||
onAddGroup: () => void;
|
|
||||||
onChange: EstimateBalanceUpdate;
|
|
||||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
|
||||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
|
||||||
threePhaseVoltageV: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leading columns up to cos φ; the power columns follow.
|
|
||||||
const LEADING_COLUMN_COUNT = 9;
|
|
||||||
const COLUMN_COUNT = 16;
|
|
||||||
|
|
||||||
/** Suggested supply values; free text stays possible. */
|
|
||||||
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
|
|
||||||
const SUPPLY_LIST_ID = "estimate-supply-options";
|
|
||||||
|
|
||||||
function NumberInput({
|
|
||||||
ariaLabel,
|
|
||||||
disabled,
|
|
||||||
fieldKey,
|
|
||||||
onCommit,
|
|
||||||
onValidityChange,
|
|
||||||
range,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
ariaLabel: string;
|
|
||||||
disabled: boolean;
|
|
||||||
fieldKey: string;
|
|
||||||
onCommit: (value: number) => void;
|
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
|
||||||
range: DecimalRange;
|
|
||||||
value: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DraftInput
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
className="estimate-cell-input is-numeric"
|
|
||||||
disabled={disabled}
|
|
||||||
format={(current: number) => formatDecimalInput(current)}
|
|
||||||
inputMode="decimal"
|
|
||||||
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
|
|
||||||
onCommit={onCommit}
|
|
||||||
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
|
||||||
parse={(text) => parseGermanDecimal(text, range)}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
|
|
||||||
const used = balance.groups.flatMap((group) =>
|
|
||||||
group.rows.map((row) => row.supply?.trim() ?? "")
|
|
||||||
);
|
|
||||||
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextInput({
|
|
||||||
ariaLabel,
|
|
||||||
disabled,
|
|
||||||
list,
|
|
||||||
maxLength,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
ariaLabel: string;
|
|
||||||
disabled: boolean;
|
|
||||||
list?: string;
|
|
||||||
maxLength: number;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
value: string | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="estimate-cell-input"
|
|
||||||
disabled={disabled}
|
|
||||||
list={list}
|
|
||||||
maxLength={maxLength}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type="text"
|
|
||||||
value={value ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{value ?? ""}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconButton({
|
|
||||||
children,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
variant = "secondary",
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
disabled: boolean;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
variant?: "secondary" | "danger" | "primary";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={label}
|
|
||||||
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
title={label}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
|
||||||
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupLabel(group: EstimateGroupSnapshot) {
|
|
||||||
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function EstimateRowLine({
|
|
||||||
disabled,
|
|
||||||
group,
|
|
||||||
onChange,
|
|
||||||
onOpenGzfPicker,
|
|
||||||
onValidityChange,
|
|
||||||
row,
|
|
||||||
rowIndex,
|
|
||||||
}: {
|
|
||||||
disabled: boolean;
|
|
||||||
group: EstimateGroupSnapshot;
|
|
||||||
onChange: EstimateBalanceUpdate;
|
|
||||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
|
||||||
row: EstimateRowSnapshot;
|
|
||||||
rowIndex: number;
|
|
||||||
}) {
|
|
||||||
const result = calculateEstimateRow(row);
|
|
||||||
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
|
||||||
const fieldKey = (field: string) => `${row.id}:${field}`;
|
|
||||||
|
|
||||||
function patch(rowPatch: EstimateRowPatch) {
|
|
||||||
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr className="estimate-row">
|
|
||||||
<td className="estimate-cost-group-cell" />
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Versorgung (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
list={SUPPLY_LIST_ID}
|
|
||||||
maxLength={estimateTextLimits.supply}
|
|
||||||
onChange={(supply) => patch({ supply })}
|
|
||||||
placeholder="ELT"
|
|
||||||
value={row.supply}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-name-cell">
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.rowName}
|
|
||||||
onChange={(name) => patch({ name })}
|
|
||||||
placeholder="Verbraucher oder Fläche"
|
|
||||||
value={row.name}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("quantity")}
|
|
||||||
onCommit={(quantity) => patch({ quantity })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.quantity}
|
|
||||||
value={row.quantity}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
aria-label={`Einheit (${rowLabel})`}
|
|
||||||
className="estimate-cell-select"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
|
||||||
value={row.unit}
|
|
||||||
>
|
|
||||||
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
|
||||||
<option key={unit} value={unit}>
|
|
||||||
{estimateRowUnitLabels[unit]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
aria-label={`Anschluss (${rowLabel})`}
|
|
||||||
className="estimate-cell-select"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({
|
|
||||||
connection: (event.target.value || null) as EstimateRowConnection | null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
value={row.connection ?? ""}
|
|
||||||
>
|
|
||||||
<option value="">–</option>
|
|
||||||
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
|
||||||
(connection) => (
|
|
||||||
<option key={connection} value={connection}>
|
|
||||||
{estimateRowConnectionLabels[connection]}
|
|
||||||
</option>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<span className="estimate-print-value">
|
|
||||||
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="estimate-unit-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("powerPerUnitW")}
|
|
||||||
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.powerPerUnitW}
|
|
||||||
value={row.powerPerUnitW}
|
|
||||||
/>
|
|
||||||
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="estimate-gzf-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("simultaneityFactor")}
|
|
||||||
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.simultaneityFactor}
|
|
||||||
value={row.simultaneityFactor}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
|
||||||
className="estimate-amev-button estimate-no-print"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
|
||||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
AMEV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`cos φ (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("cosPhi")}
|
|
||||||
onCommit={(cosPhi) => patch({ cosPhi })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.cosPhi}
|
|
||||||
value={row.cosPhi}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.av} />
|
|
||||||
<td className="estimate-center">
|
|
||||||
<input
|
|
||||||
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
|
||||||
checked={row.safetySupply}
|
|
||||||
className="form-check-input estimate-no-print"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
|
||||||
</td>
|
|
||||||
<PowerCells
|
|
||||||
className={row.safetySupply ? "" : "is-muted"}
|
|
||||||
power={result.sv}
|
|
||||||
/>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Anmerkung (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.remark}
|
|
||||||
onChange={(remark) => patch({ remark })}
|
|
||||||
value={row.remark}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-actions-cell estimate-no-print">
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Zeile löschen (${rowLabel})`}
|
|
||||||
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spreadsheet-like editor in the layout of the office template
|
|
||||||
* "Leistungsbilanz – KG400": one section per cost group with title and
|
|
||||||
* subtotal row, totals, reserve and maximum current at the end.
|
|
||||||
*/
|
|
||||||
export function EstimateBalanceSheet({
|
|
||||||
balance,
|
|
||||||
disabled,
|
|
||||||
onAddGroup,
|
|
||||||
onChange,
|
|
||||||
onEditGroup,
|
|
||||||
onOpenGzfPicker,
|
|
||||||
onValidityChange,
|
|
||||||
threePhaseVoltageV,
|
|
||||||
}: EstimateBalanceSheetProps) {
|
|
||||||
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
|
||||||
|
|
||||||
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
|
||||||
const rowText =
|
|
||||||
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
onChange((current) => removeEstimateGroup(current, group.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="estimate-sheet-wrap">
|
|
||||||
<datalist id={SUPPLY_LIST_ID}>
|
|
||||||
{supplyOptions(balance).map((supply) => (
|
|
||||||
<option key={supply} value={supply} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
<table className="estimate-sheet">
|
|
||||||
<colgroup>
|
|
||||||
<col className="estimate-col-kg" />
|
|
||||||
<col className="estimate-col-supply" />
|
|
||||||
<col className="estimate-col-name" />
|
|
||||||
<col className="estimate-col-quantity" />
|
|
||||||
<col className="estimate-col-unit" />
|
|
||||||
<col className="estimate-col-connection" />
|
|
||||||
<col className="estimate-col-power" />
|
|
||||||
<col className="estimate-col-gzf" />
|
|
||||||
<col className="estimate-col-cosphi" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-sv" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-remark" />
|
|
||||||
<col className="estimate-col-actions" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowSpan={2}>KG</th>
|
|
||||||
<th rowSpan={2}>
|
|
||||||
Versorgung
|
|
||||||
<span className="estimate-th-hint">ELT/MSR …</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>Verbraucher / Flächen</th>
|
|
||||||
<th colSpan={2}>
|
|
||||||
Anzahl / Fläche
|
|
||||||
<span className="estimate-th-hint">St. / m²</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>
|
|
||||||
Anschl.
|
|
||||||
<span className="estimate-th-hint">Fest/Steckd.</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>
|
|
||||||
Einzelleist.
|
|
||||||
<span className="estimate-th-hint">W bzw. W/m²</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>GL-Faktor</th>
|
|
||||||
<th rowSpan={2}>cos φ</th>
|
|
||||||
<th colSpan={2}>Nötige AV-Leistung</th>
|
|
||||||
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
|
||||||
<th rowSpan={2}>Anmerkungen</th>
|
|
||||||
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Wert</th>
|
|
||||||
<th>Einh.</th>
|
|
||||||
<th>kW</th>
|
|
||||||
<th>kVA</th>
|
|
||||||
<th>ja/nein</th>
|
|
||||||
<th>kW</th>
|
|
||||||
<th>kVA</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{balance.groups.map((group, groupIndex) => {
|
|
||||||
const groupResult = result.groups[groupIndex]!;
|
|
||||||
const label = groupLabel(group) || "Kostengruppe";
|
|
||||||
return (
|
|
||||||
<tbody className="estimate-group" key={group.id}>
|
|
||||||
<tr className="estimate-group-title-row">
|
|
||||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
|
||||||
<td colSpan={COLUMN_COUNT - 2}>
|
|
||||||
<div className="estimate-group-title">
|
|
||||||
<span className="estimate-group-name">{group.title}</span>
|
|
||||||
<label
|
|
||||||
className={
|
|
||||||
group.editor?.trim()
|
|
||||||
? "estimate-group-editor"
|
|
||||||
: "estimate-group-editor is-empty"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>bearbeitet von</span>
|
|
||||||
<input
|
|
||||||
aria-label={`Bearbeitet von (${label})`}
|
|
||||||
className="estimate-cell-input"
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.groupEditor}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange((current) =>
|
|
||||||
updateEstimateGroup(current, group.id, {
|
|
||||||
editor: event.target.value,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Gewerk / Name"
|
|
||||||
type="text"
|
|
||||||
value={group.editor ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-actions-cell estimate-no-print">
|
|
||||||
<div className="estimate-group-actions">
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Zeile hinzufügen (${label})`}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((current) => addEstimateRow(current, group.id).balance)
|
|
||||||
}
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Gruppe bearbeiten (${label})`}
|
|
||||||
onClick={() => onEditGroup(group)}
|
|
||||||
>
|
|
||||||
<Pencil aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled || groupIndex === 0}
|
|
||||||
label={`Gruppe nach oben (${label})`}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowUp aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled || groupIndex === balance.groups.length - 1}
|
|
||||||
label={`Gruppe nach unten (${label})`}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowDown aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Gruppe löschen (${label})`}
|
|
||||||
onClick={() => confirmRemoveGroup(group)}
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{group.rows.map((row, rowIndex) => (
|
|
||||||
<EstimateRowLine
|
|
||||||
disabled={disabled}
|
|
||||||
group={group}
|
|
||||||
key={row.id}
|
|
||||||
onChange={onChange}
|
|
||||||
onOpenGzfPicker={onOpenGzfPicker}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
row={row}
|
|
||||||
rowIndex={rowIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!group.rows.length ? (
|
|
||||||
<tr className="estimate-empty-row estimate-no-print">
|
|
||||||
<td />
|
|
||||||
<td colSpan={COLUMN_COUNT - 1}>
|
|
||||||
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
<tr className="estimate-subtotal-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Summe {label}
|
|
||||||
</td>
|
|
||||||
<PowerCells power={groupResult.av} />
|
|
||||||
<td />
|
|
||||||
<PowerCells power={groupResult.sv} />
|
|
||||||
<td />
|
|
||||||
<td className="estimate-no-print" />
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<tfoot>
|
|
||||||
<tr className="estimate-no-print estimate-add-group-row">
|
|
||||||
<td colSpan={COLUMN_COUNT}>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onAddGroup}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="me-1" size={14} />
|
|
||||||
Kostengruppe hinzufügen
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Gesamtsumme
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.total.av} />
|
|
||||||
<td />
|
|
||||||
<PowerCells power={result.total.sv} />
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
|
||||||
Reserveleistung
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="estimate-unit-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel="Reserve AV in Prozent"
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey="balance:reserveAvPercent"
|
|
||||||
onCommit={(reserveAvPercent) =>
|
|
||||||
onChange((current) => ({ ...current, reserveAvPercent }))
|
|
||||||
}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.reservePercent}
|
|
||||||
value={balance.reserveAvPercent}
|
|
||||||
/>
|
|
||||||
<span className="estimate-unit-label">%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.reserve.av} />
|
|
||||||
<td>
|
|
||||||
<div className="estimate-unit-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel="Reserve SV in Prozent"
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey="balance:reserveSvPercent"
|
|
||||||
onCommit={(reserveSvPercent) =>
|
|
||||||
onChange((current) => ({ ...current, reserveSvPercent }))
|
|
||||||
}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.reservePercent}
|
|
||||||
value={balance.reserveSvPercent}
|
|
||||||
/>
|
|
||||||
<span className="estimate-unit-label">%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.reserve.sv} />
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row is-strong">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Leistung inkl. Reserve
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.withReserve.av} />
|
|
||||||
<td />
|
|
||||||
<PowerCells power={result.withReserve.sv} />
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Maximalstrom
|
|
||||||
<span className="estimate-total-hint">
|
|
||||||
{threePhaseVoltageV > 0
|
|
||||||
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
|
||||||
: " (Drehstromspannung im Projekt fehlt)"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-number" colSpan={2}>
|
|
||||||
{formatAmpere(result.maxCurrentA.av)} A
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
<td className="estimate-number" colSpan={2}>
|
|
||||||
{formatAmpere(result.maxCurrentA.sv)} A
|
|
||||||
</td>
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
roundEstimateValue,
|
|
||||||
type EstimateComparison,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
|
|
||||||
|
|
||||||
function deltaClass(deltaKw: number) {
|
|
||||||
if (roundEstimateValue(deltaKw, 2) === 0) {
|
|
||||||
return "estimate-number";
|
|
||||||
}
|
|
||||||
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
|
|
||||||
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
|
|
||||||
return (
|
|
||||||
<div className="estimate-sheet-wrap">
|
|
||||||
<table className="estimate-sheet estimate-comparison">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>KG</th>
|
|
||||||
<th>Titel</th>
|
|
||||||
<th>geschätzt AV kW</th>
|
|
||||||
<th>detailliert kW</th>
|
|
||||||
<th>Δ kW</th>
|
|
||||||
<th>Δ %</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{comparison.groups.map((group) => (
|
|
||||||
<tr key={group.groupId}>
|
|
||||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
|
||||||
<td>
|
|
||||||
{group.title}
|
|
||||||
{group.detailedRowCount ? (
|
|
||||||
<span className="estimate-th-hint">
|
|
||||||
{group.detailedRowCount === 1
|
|
||||||
? "1 Gerätezeile"
|
|
||||||
: `${group.detailedRowCount} Gerätezeilen`}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
|
|
||||||
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
|
|
||||||
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
|
|
||||||
<td className={deltaClass(group.deltaKw)}>
|
|
||||||
{formatDeltaPercent(group.deltaPercent)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
<tr className="estimate-unassigned-row">
|
|
||||||
<td />
|
|
||||||
<td>
|
|
||||||
ohne Kostengruppe / nicht zugeordnet
|
|
||||||
{comparison.unassigned.rowCount ? (
|
|
||||||
<span className="estimate-th-hint">
|
|
||||||
{comparison.unassigned.rowCount === 1
|
|
||||||
? "1 Gerätezeile"
|
|
||||||
: `${comparison.unassigned.rowCount} Gerätezeilen`}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td className="estimate-number">–</td>
|
|
||||||
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
|
|
||||||
<td className="estimate-number">–</td>
|
|
||||||
<td className="estimate-number">–</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="estimate-total-row is-strong">
|
|
||||||
<td />
|
|
||||||
<td className="estimate-total-label">Summe</td>
|
|
||||||
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
|
|
||||||
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
|
|
||||||
<td className={deltaClass(comparison.total.deltaKw)}>
|
|
||||||
{formatSignedKw(comparison.total.deltaKw)}
|
|
||||||
</td>
|
|
||||||
<td className={deltaClass(comparison.total.deltaKw)}>
|
|
||||||
{formatDeltaPercent(comparison.total.deltaPercent)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
||||||
|
|
||||||
interface DraftInputProps<T> {
|
|
||||||
ariaLabel: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
format: (value: T) => string;
|
|
||||||
/** Printed text; defaults to the input text. */
|
|
||||||
printFormat?: (value: T) => string;
|
|
||||||
inputMode?: "decimal" | "text";
|
|
||||||
maxLength?: number;
|
|
||||||
onCommit: (value: T) => void;
|
|
||||||
/** Reports whether the raw text is currently valid (false while invalid). */
|
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
|
||||||
parse: (text: string) => DraftParseResult<T>;
|
|
||||||
placeholder?: string;
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input that keeps the raw text the user typed. Only a valid text is
|
|
||||||
* committed into the document; an invalid text stays visible with a red
|
|
||||||
* border and is never saved. A value changed from outside (reload, undo)
|
|
||||||
* replaces the text unless the text already represents that value.
|
|
||||||
*/
|
|
||||||
export function DraftInput<T>({
|
|
||||||
ariaLabel,
|
|
||||||
className = "estimate-cell-input",
|
|
||||||
disabled,
|
|
||||||
format,
|
|
||||||
printFormat,
|
|
||||||
inputMode = "text",
|
|
||||||
maxLength,
|
|
||||||
onCommit,
|
|
||||||
onValidityChange,
|
|
||||||
parse,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
}: DraftInputProps<T>) {
|
|
||||||
const [text, setText] = useState(() => format(value));
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [syncedValue, setSyncedValue] = useState(value);
|
|
||||||
|
|
||||||
if (!Object.is(syncedValue, value)) {
|
|
||||||
// Adjusting state while rendering (instead of an effect) avoids a frame
|
|
||||||
// with stale text after an external change.
|
|
||||||
setSyncedValue(value);
|
|
||||||
const parsed = parse(text);
|
|
||||||
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
|
||||||
setText(format(value));
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validityRef = useRef(onValidityChange);
|
|
||||||
useEffect(() => {
|
|
||||||
validityRef.current = onValidityChange;
|
|
||||||
});
|
|
||||||
const isValid = error === null;
|
|
||||||
useEffect(() => {
|
|
||||||
validityRef.current?.(isValid);
|
|
||||||
}, [isValid]);
|
|
||||||
// A removed input (deleted row) must not leave an invalid marker behind.
|
|
||||||
useEffect(() => () => validityRef.current?.(true), []);
|
|
||||||
|
|
||||||
function handleChange(nextText: string) {
|
|
||||||
setText(nextText);
|
|
||||||
const parsed = parse(nextText);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
setError(parsed.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
if (!Object.is(parsed.value, value)) {
|
|
||||||
setSyncedValue(parsed.value);
|
|
||||||
onCommit(parsed.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
|
||||||
if (error === null) {
|
|
||||||
setText(format(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={error ? `${className} is-invalid` : className}
|
|
||||||
disabled={disabled}
|
|
||||||
inputMode={inputMode}
|
|
||||||
maxLength={maxLength}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onChange={(event) => handleChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
title={error ?? undefined}
|
|
||||||
type="text"
|
|
||||||
value={text}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">
|
|
||||||
{printFormat ? printFormat(value) : error ? format(value) : text}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { type FormEvent, useState } from "react";
|
|
||||||
import { FormModal } from "../components/form-modal";
|
|
||||||
import {
|
|
||||||
estimateDefaultGroups,
|
|
||||||
type EstimateGroupSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import { estimateTextLimits } from "./estimate-balance-document";
|
|
||||||
|
|
||||||
export interface EstimateGroupFormValues {
|
|
||||||
costGroup: string;
|
|
||||||
title: string;
|
|
||||||
editor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EstimateGroupModalProps {
|
|
||||||
group: EstimateGroupSnapshot | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (values: EstimateGroupFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
|
|
||||||
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
|
|
||||||
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
|
|
||||||
const [title, setTitle] = useState(group?.title ?? "");
|
|
||||||
const [editor, setEditor] = useState(group?.editor ?? "");
|
|
||||||
|
|
||||||
function applyTemplate(index: string) {
|
|
||||||
const template = estimateDefaultGroups[Number(index)];
|
|
||||||
if (template) {
|
|
||||||
setCostGroup(template.costGroup);
|
|
||||||
setTitle(template.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!title.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave({
|
|
||||||
costGroup: costGroup.trim(),
|
|
||||||
title: title.trim(),
|
|
||||||
editor: editor.trim() || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormModal
|
|
||||||
isSaving={false}
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
submitDisabled={!title.trim()}
|
|
||||||
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
|
|
||||||
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
|
|
||||||
>
|
|
||||||
<div className="row g-3">
|
|
||||||
{!group ? (
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-template">
|
|
||||||
Aus Vorlage KG 400 übernehmen
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
defaultValue=""
|
|
||||||
id="estimate-group-template"
|
|
||||||
onChange={(event) => applyTemplate(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">– Vorlage wählen –</option>
|
|
||||||
{estimateDefaultGroups.map((template, index) => (
|
|
||||||
<option key={`${template.costGroup}-${template.title}`} value={index}>
|
|
||||||
{[template.costGroup, template.title].filter(Boolean).join(" ")}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="col-12 col-md-3">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-cost-group">
|
|
||||||
KG (DIN 276)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-group-cost-group"
|
|
||||||
maxLength={estimateTextLimits.costGroup}
|
|
||||||
onChange={(event) => setCostGroup(event.target.value)}
|
|
||||||
placeholder="z. B. 440"
|
|
||||||
value={costGroup}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-9">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-title">
|
|
||||||
Titel
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-group-title"
|
|
||||||
maxLength={estimateTextLimits.groupTitle}
|
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
|
||||||
placeholder="z. B. Starkstromanlagen"
|
|
||||||
required
|
|
||||||
value={title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-editor">
|
|
||||||
Bearbeitet von
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-group-editor"
|
|
||||||
maxLength={estimateTextLimits.groupEditor}
|
|
||||||
onChange={(event) => setEditor(event.target.value)}
|
|
||||||
placeholder="Gewerk oder Name (optional)"
|
|
||||||
value={editor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
|
||||||
<p className="text-secondary small mb-0">
|
|
||||||
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
|
|
||||||
detaillierten Bilanz keiner Kostengruppe zugeordnet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
|
|
||||||
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
|
|
||||||
import { formatIsoDateGerman } from "./estimate-balance-format";
|
|
||||||
|
|
||||||
interface EstimatePrintHeaderProps {
|
|
||||||
project: ProjectDto;
|
|
||||||
balance: EstimateBalanceDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cssString(value: string) {
|
|
||||||
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Print-only title block and page footer of the rough power balance in the
|
|
||||||
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
|
|
||||||
* meta data, "Seite x von y". Hidden on screen.
|
|
||||||
*/
|
|
||||||
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
|
|
||||||
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
|
|
||||||
const [hasLogo, setHasLogo] = useState(false);
|
|
||||||
const [logoVersion, setLogoVersion] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getDocumentBranding()
|
|
||||||
.then((branding) => {
|
|
||||||
setOffice(branding.office);
|
|
||||||
setHasLogo(Boolean(branding.logo));
|
|
||||||
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setOffice(null);
|
|
||||||
setHasLogo(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
|
|
||||||
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
|
|
||||||
const footer = `
|
|
||||||
@page estimate-balance {
|
|
||||||
size: A4 landscape;
|
|
||||||
margin: 10mm 10mm 13mm;
|
|
||||||
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
|
||||||
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; 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 (
|
|
||||||
<>
|
|
||||||
<style>{footer}</style>
|
|
||||||
<header className="estimate-print-titleblock" aria-hidden="true">
|
|
||||||
<div className="estimate-print-logo">
|
|
||||||
{hasLogo ? (
|
|
||||||
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
|
|
||||||
) : (
|
|
||||||
<span>{office?.officeName ?? ""}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="estimate-print-title">
|
|
||||||
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
|
|
||||||
<div className="estimate-print-heading">
|
|
||||||
<span className="estimate-print-stand">{balance.name}</span>
|
|
||||||
<span className="estimate-print-project">{project.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<dl className="estimate-print-meta">
|
|
||||||
<div>
|
|
||||||
<dt>Projekt-Nr.</dt>
|
|
||||||
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Bauherr</dt>
|
|
||||||
<dd>{project.buildingOwner ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Anschrift</dt>
|
|
||||||
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<dl className="estimate-print-meta is-tinted">
|
|
||||||
<div>
|
|
||||||
<dt>Leistungsphase</dt>
|
|
||||||
<dd>{balance.workStage ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Datum</dt>
|
|
||||||
<dd>{date ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Bearbeitung</dt>
|
|
||||||
<dd>{project.projectEditor ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Büro</dt>
|
|
||||||
<dd>{office?.officeName ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</header>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
|
|
||||||
import type {
|
|
||||||
CircuitTreeCircuitDto,
|
|
||||||
CircuitTreeProtectionDeviceDto,
|
|
||||||
CircuitTreeResponseDto,
|
|
||||||
DistributionBoardDto,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure read model for the print view (cover page, board overview and board
|
|
||||||
* plans). It only derives display values from the existing circuit-tree
|
|
||||||
* responses and never changes project data.
|
|
||||||
*/
|
|
||||||
export interface PrintBoardSummary {
|
|
||||||
board: DistributionBoardDto;
|
|
||||||
circuitListId: string;
|
|
||||||
tree: CircuitTreeResponseDto;
|
|
||||||
floorName: string | null;
|
|
||||||
circuitCount: number;
|
|
||||||
/** Σ quantity × power per unit, without any simultaneity factor. */
|
|
||||||
installedPowerKw: number;
|
|
||||||
/** Σ row totals, i.e. with the device-row simultaneity factors. */
|
|
||||||
devicePowerKw: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
/** Device power with the board simultaneity factor applied. */
|
|
||||||
demandPowerKw: number;
|
|
||||||
/**
|
|
||||||
* Symmetrical three-phase design current from the apparent power
|
|
||||||
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
|
|
||||||
*/
|
|
||||||
designCurrentA: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizePrintBoard(input: {
|
|
||||||
board: DistributionBoardDto;
|
|
||||||
circuitListId: string;
|
|
||||||
tree: CircuitTreeResponseDto;
|
|
||||||
floorName: string | null;
|
|
||||||
}): PrintBoardSummary {
|
|
||||||
const { tree } = input;
|
|
||||||
let circuitCount = 0;
|
|
||||||
let installedPowerKw = 0;
|
|
||||||
let apparentPowerKva = 0;
|
|
||||||
for (const section of tree.sections) {
|
|
||||||
for (const circuit of section.circuits) {
|
|
||||||
circuitCount += 1;
|
|
||||||
for (const row of circuit.deviceRows) {
|
|
||||||
installedPowerKw += row.quantity * row.powerPerUnit;
|
|
||||||
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
circuitCount,
|
|
||||||
installedPowerKw,
|
|
||||||
devicePowerKw: tree.distributionBoardTotalPower,
|
|
||||||
simultaneityFactor,
|
|
||||||
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
|
|
||||||
designCurrentA:
|
|
||||||
(apparentPowerKva * simultaneityFactor * 1000) /
|
|
||||||
(Math.sqrt(3) * tree.threePhaseVoltageV),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrintBoardHierarchyEntry {
|
|
||||||
summary: PrintBoardSummary;
|
|
||||||
depth: number;
|
|
||||||
parentBoardId: string | null;
|
|
||||||
/** Own demand plus the plain sum of all resolved downstream boards. */
|
|
||||||
cumulatedDemandPowerKw: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertPrintRevisionConsistency(
|
|
||||||
revision: number,
|
|
||||||
summaries: readonly PrintBoardSummary[],
|
|
||||||
finalRevision: number
|
|
||||||
) {
|
|
||||||
if (finalRevision !== revision || summaries.some(({ tree }) => tree.currentRevision !== revision)) {
|
|
||||||
throw new Error("Das Projekt wurde während des Ladens geändert. Bitte die Seitenansicht neu laden.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
|
|
||||||
* decided. A board is shown below another board only when its `fedFrom`
|
|
||||||
* text equals that board's number or name (case-insensitive). The
|
|
||||||
* cumulated value is a plain sum without an upstream simultaneity factor.
|
|
||||||
* Unresolved texts and cycles keep a board at the top level.
|
|
||||||
*/
|
|
||||||
export function buildPreliminaryBoardHierarchy(
|
|
||||||
summaries: PrintBoardSummary[]
|
|
||||||
): PrintBoardHierarchyEntry[] {
|
|
||||||
const normalize = (value: string | null) =>
|
|
||||||
value?.trim().toLocaleLowerCase("de") || null;
|
|
||||||
const parentById = new Map<string, string | null>();
|
|
||||||
for (const summary of summaries) {
|
|
||||||
const fedFrom = normalize(summary.board.fedFrom);
|
|
||||||
const candidates = fedFrom
|
|
||||||
? summaries.filter(
|
|
||||||
(candidate) =>
|
|
||||||
candidate !== summary &&
|
|
||||||
(normalize(candidate.board.boardNumber) === fedFrom ||
|
|
||||||
normalize(candidate.board.name) === fedFrom)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const parent = candidates.length === 1 ? candidates[0] : undefined;
|
|
||||||
parentById.set(summary.board.id, parent?.board.id ?? null);
|
|
||||||
}
|
|
||||||
for (const summary of summaries) {
|
|
||||||
const visited = new Set<string>([summary.board.id]);
|
|
||||||
let current = parentById.get(summary.board.id) ?? null;
|
|
||||||
while (current !== null) {
|
|
||||||
if (visited.has(current)) {
|
|
||||||
parentById.set(summary.board.id, null);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
visited.add(current);
|
|
||||||
current = parentById.get(current) ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const childrenOf = (boardId: string | null) =>
|
|
||||||
summaries.filter(
|
|
||||||
(summary) => parentById.get(summary.board.id) === boardId
|
|
||||||
);
|
|
||||||
const cumulated = (summary: PrintBoardSummary): number =>
|
|
||||||
summary.demandPowerKw +
|
|
||||||
childrenOf(summary.board.id).reduce(
|
|
||||||
(sum, child) => sum + cumulated(child),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
const entries: PrintBoardHierarchyEntry[] = [];
|
|
||||||
const walk = (summary: PrintBoardSummary, depth: number) => {
|
|
||||||
entries.push({
|
|
||||||
summary,
|
|
||||||
depth,
|
|
||||||
parentBoardId: parentById.get(summary.board.id) ?? null,
|
|
||||||
cumulatedDemandPowerKw: cumulated(summary),
|
|
||||||
});
|
|
||||||
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
|
|
||||||
};
|
|
||||||
childrenOf(null).forEach((root) => walk(root, 0));
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
const kilowattFormat = new Intl.NumberFormat("de-DE", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
const unitPowerFormat = new Intl.NumberFormat("de-DE", {
|
|
||||||
minimumFractionDigits: 3,
|
|
||||||
maximumFractionDigits: 3,
|
|
||||||
});
|
|
||||||
const currentFormat = new Intl.NumberFormat("de-DE", {
|
|
||||||
minimumFractionDigits: 1,
|
|
||||||
maximumFractionDigits: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const formatKilowatt = (value: number) => kilowattFormat.format(value);
|
|
||||||
export const formatUnitPower = (value: number) => unitPowerFormat.format(value);
|
|
||||||
export const formatFactor = (value: number) => kilowattFormat.format(value);
|
|
||||||
export const formatCurrent = (value: number) => currentFormat.format(value);
|
|
||||||
|
|
||||||
export function formatProtectionDevice(
|
|
||||||
protection: CircuitTreeProtectionDeviceDto | undefined
|
|
||||||
): string | null {
|
|
||||||
if (!protection) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parts = [
|
|
||||||
protectionDeviceTypeLabels[protection.type],
|
|
||||||
protection.tripCharacteristic,
|
|
||||||
`${protection.ratedCurrentA} A`,
|
|
||||||
protection.fuseUtilizationCategory,
|
|
||||||
].filter(Boolean);
|
|
||||||
let text = parts.join(" ");
|
|
||||||
if (protection.ratedResidualCurrentMa !== undefined) {
|
|
||||||
text += `, ${protection.ratedResidualCurrentMa} mA`;
|
|
||||||
if (protection.rcdType) {
|
|
||||||
text += ` Typ ${protection.rcdType}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCircuitCable(circuit: CircuitTreeCircuitDto): string | null {
|
|
||||||
return [circuit.cableType, circuit.cableCrossSection].filter(Boolean).join(" · ") || null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,481 +0,0 @@
|
||||||
/* Print view (Seitenansicht) in the kbp corporate design. Colors are
|
|
||||||
derived from the kbp logo and presentation material; change them only
|
|
||||||
here.
|
|
||||||
All selectors are scoped to .print-view. The @page rules (format, margins,
|
|
||||||
page numbers) are injected by the print page because they need project
|
|
||||||
data. */
|
|
||||||
.print-view {
|
|
||||||
--pd-blue: #476088;
|
|
||||||
--pd-lilac: #9198c4;
|
|
||||||
--pd-line: #d6dae5;
|
|
||||||
--pd-tint: #f5f6fa;
|
|
||||||
--pd-text: #2b2b2b;
|
|
||||||
--pd-muted: #6b7280;
|
|
||||||
--pd-accent: #c9782a;
|
|
||||||
--pd-font: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
|
||||||
--pd-landscape-width: 297mm;
|
|
||||||
--pd-landscape-height: 210mm;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #e7e9ef;
|
|
||||||
color: var(--pd-text);
|
|
||||||
font-family: var(--pd-font);
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
.print-view.pd-a3 {
|
|
||||||
--pd-landscape-width: 420mm;
|
|
||||||
--pd-landscape-height: 297mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pd-toolbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid var(--pd-line);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.pd-toolbar-title {
|
|
||||||
margin-right: auto;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-toolbar .pd-toolbar-message {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.pd-toolbar .pd-toolbar-error {
|
|
||||||
color: #b04035;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pd-pages {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 24px 16px 48px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.pd-sheet {
|
|
||||||
position: relative;
|
|
||||||
flex: none;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
font-size: 9pt;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.pd-portrait {
|
|
||||||
width: 210mm;
|
|
||||||
min-height: 297mm;
|
|
||||||
padding: 18mm 18mm 22mm;
|
|
||||||
page: lb-portrait;
|
|
||||||
}
|
|
||||||
.pd-landscape {
|
|
||||||
width: var(--pd-landscape-width);
|
|
||||||
min-height: var(--pd-landscape-height);
|
|
||||||
padding: 12mm 12mm 18mm;
|
|
||||||
page: lb-landscape;
|
|
||||||
}
|
|
||||||
.pd-a3 .pd-landscape {
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-view table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.print-view caption {
|
|
||||||
caption-side: top;
|
|
||||||
padding: 0 0 1.5mm;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-align: left;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.print-view sub {
|
|
||||||
font-size: 0.75em;
|
|
||||||
}
|
|
||||||
.pd-r {
|
|
||||||
text-align: right !important;
|
|
||||||
}
|
|
||||||
.pd-placeholder {
|
|
||||||
color: var(--pd-accent);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.pd-open {
|
|
||||||
color: var(--pd-accent);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-multiline {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
.pd-logo {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.pd-logo-missing {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px dashed var(--pd-line);
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cover */
|
|
||||||
.pd-cover-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
border-bottom: 2px solid var(--pd-blue);
|
|
||||||
padding-bottom: 6mm;
|
|
||||||
}
|
|
||||||
.pd-cover-kicker {
|
|
||||||
margin-top: 3mm;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 10pt;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.pd-cover-logo {
|
|
||||||
height: 15mm;
|
|
||||||
width: auto;
|
|
||||||
max-width: 60mm;
|
|
||||||
}
|
|
||||||
span.pd-cover-logo {
|
|
||||||
width: 40mm;
|
|
||||||
}
|
|
||||||
.pd-cover-title {
|
|
||||||
margin: 14mm 0 10mm;
|
|
||||||
}
|
|
||||||
.pd-cover-title h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 30pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-cover-title p {
|
|
||||||
margin: 2mm 0 0;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 13pt;
|
|
||||||
}
|
|
||||||
.pd-kv {
|
|
||||||
margin-bottom: 7mm;
|
|
||||||
}
|
|
||||||
.pd-kv th,
|
|
||||||
.pd-kv td {
|
|
||||||
border: 1px solid var(--pd-line);
|
|
||||||
padding: 2mm 3mm;
|
|
||||||
font-size: 9.5pt;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.pd-kv th {
|
|
||||||
width: 42mm;
|
|
||||||
background: var(--pd-tint);
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.pd-cover-cols {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6mm;
|
|
||||||
}
|
|
||||||
.pd-cover-cols .pd-kv th {
|
|
||||||
width: 30mm;
|
|
||||||
}
|
|
||||||
.pd-block {
|
|
||||||
margin-bottom: 7mm;
|
|
||||||
}
|
|
||||||
.pd-col-index {
|
|
||||||
width: 14mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generic grid tables */
|
|
||||||
.pd-grid th,
|
|
||||||
.pd-grid td {
|
|
||||||
border: 1px solid var(--pd-line);
|
|
||||||
padding: 1.3mm 2mm;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.pd-grid > thead > tr > th:not(.pd-tb-cell) {
|
|
||||||
background: var(--pd-blue);
|
|
||||||
border-color: var(--pd-blue);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: bold;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer (screen only; printed pages use @page margin boxes) */
|
|
||||||
.pd-sheet-footer {
|
|
||||||
position: absolute;
|
|
||||||
left: 12mm;
|
|
||||||
right: 12mm;
|
|
||||||
bottom: 7mm;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-top: 1px solid var(--pd-line);
|
|
||||||
padding-top: 2mm;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
}
|
|
||||||
.pd-portrait .pd-sheet-footer {
|
|
||||||
left: 18mm;
|
|
||||||
right: 18mm;
|
|
||||||
bottom: 10mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title block */
|
|
||||||
.pd-grid th.pd-tb-cell {
|
|
||||||
border: none;
|
|
||||||
padding: 0 0 4mm;
|
|
||||||
background: none;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.pd-titleblock {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 28mm 1fr auto auto;
|
|
||||||
border: 1.5px solid var(--pd-blue);
|
|
||||||
margin-bottom: 4mm;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
.pd-grid .pd-titleblock {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.pd-titleblock > * {
|
|
||||||
padding: 2.5mm 3.5mm;
|
|
||||||
border-left: 1px solid var(--pd-line);
|
|
||||||
}
|
|
||||||
.pd-titleblock > :first-child {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
.pd-tb-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 17mm;
|
|
||||||
}
|
|
||||||
.pd-tb-logo-image {
|
|
||||||
max-height: 10mm;
|
|
||||||
}
|
|
||||||
span.pd-tb-logo-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 10mm;
|
|
||||||
}
|
|
||||||
.pd-tb-kicker {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.pd-tb-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4mm;
|
|
||||||
margin-top: 1mm;
|
|
||||||
}
|
|
||||||
.pd-tb-number {
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-tb-name {
|
|
||||||
font-size: 14pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-tb-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
gap: 0.8mm 4mm;
|
|
||||||
align-content: center;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
.pd-tb-grid div {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
.pd-tb-grid dt {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.pd-tb-grid dd {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-tb-meta {
|
|
||||||
background: var(--pd-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Board plan table */
|
|
||||||
.pd-plan {
|
|
||||||
font-size: 7.6pt;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.pd-a3 .pd-plan {
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
.pd-plan th,
|
|
||||||
.pd-plan td {
|
|
||||||
padding: 0.75mm 1.6mm;
|
|
||||||
}
|
|
||||||
.pd-plan td.pd-bmk {
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-group td {
|
|
||||||
background: var(--pd-tint);
|
|
||||||
border-top: 1.5px solid var(--pd-lilac);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-component td,
|
|
||||||
.pd-plan tr.pd-component td.pd-bmk,
|
|
||||||
.pd-plan tr.pd-device td {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-device td:nth-child(2) {
|
|
||||||
padding-left: 6mm;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-reserve td {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-subtotal td {
|
|
||||||
border-bottom: 1.5px solid var(--pd-line);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-plan .pd-sub {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.pd-plan tfoot td {
|
|
||||||
background: var(--pd-tint);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-plan tfoot tr.pd-grand td {
|
|
||||||
background: var(--pd-blue);
|
|
||||||
border-color: var(--pd-blue);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.pd-plan tr {
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overview */
|
|
||||||
.pd-overview {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 62mm 1fr;
|
|
||||||
gap: 6mm;
|
|
||||||
}
|
|
||||||
.pd-a3 .pd-overview {
|
|
||||||
grid-template-columns: 90mm 1fr;
|
|
||||||
}
|
|
||||||
.pd-structure h2 {
|
|
||||||
margin: 0 0 3mm;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 10pt;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.pd-tree,
|
|
||||||
.pd-tree ul {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.pd-tree ul {
|
|
||||||
margin-left: 5mm;
|
|
||||||
padding-left: 4mm;
|
|
||||||
border-left: 1.5px solid var(--pd-lilac);
|
|
||||||
}
|
|
||||||
.pd-tree li {
|
|
||||||
position: relative;
|
|
||||||
margin: 2.5mm 0;
|
|
||||||
}
|
|
||||||
.pd-tree ul > li::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: -4mm;
|
|
||||||
top: 5mm;
|
|
||||||
width: 4mm;
|
|
||||||
border-top: 1.5px solid var(--pd-lilac);
|
|
||||||
}
|
|
||||||
.pd-node {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.3mm;
|
|
||||||
padding: 1.5mm 2.5mm;
|
|
||||||
border: 1px solid var(--pd-line);
|
|
||||||
border-left: 3px solid var(--pd-blue);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.pd-node.pd-feed {
|
|
||||||
border-left-color: var(--pd-accent);
|
|
||||||
}
|
|
||||||
.pd-node span {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
.pd-node em {
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 8.5pt;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-note {
|
|
||||||
margin: 4mm 0 1mm;
|
|
||||||
padding: 2.5mm 3mm;
|
|
||||||
border-left: 3px solid var(--pd-accent);
|
|
||||||
background: #fbf4ec;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
.pd-legend {
|
|
||||||
margin: 1mm 0 0;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
.print-view {
|
|
||||||
background: #fff !important;
|
|
||||||
}
|
|
||||||
/* The dark theme sets color-scheme: dark on the root; Chrome then paints
|
|
||||||
the page margins dark. Printing always uses the light scheme. */
|
|
||||||
html:has(.print-view) {
|
|
||||||
color-scheme: light !important;
|
|
||||||
}
|
|
||||||
.pd-toolbar,
|
|
||||||
.pd-sheet-footer {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
.pd-pages {
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
.pd-sheet {
|
|
||||||
width: auto;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
break-after: page;
|
|
||||||
}
|
|
||||||
.pd-sheet:last-child {
|
|
||||||
break-after: auto;
|
|
||||||
}
|
|
||||||
.print-view * {
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,625 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import type {
|
|
||||||
CircuitTreeCircuitDto,
|
|
||||||
CircuitTreeComponentDto,
|
|
||||||
CircuitTreeDeviceRowDto,
|
|
||||||
OfficeProfileDto,
|
|
||||||
ProjectDto,
|
|
||||||
ProjectSnapshotMetadataDto,
|
|
||||||
} from "../types";
|
|
||||||
import {
|
|
||||||
formatCircuitCable,
|
|
||||||
formatCurrent,
|
|
||||||
formatFactor,
|
|
||||||
formatKilowatt,
|
|
||||||
formatProtectionDevice,
|
|
||||||
formatUnitPower,
|
|
||||||
type PrintBoardHierarchyEntry,
|
|
||||||
} from "./print-document-model";
|
|
||||||
|
|
||||||
export interface PrintDocumentProps {
|
|
||||||
project: ProjectDto;
|
|
||||||
office: OfficeProfileDto;
|
|
||||||
entries: PrintBoardHierarchyEntry[];
|
|
||||||
namedSnapshots: ProjectSnapshotMetadataDto[];
|
|
||||||
logoSrc: string | null;
|
|
||||||
printedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
|
|
||||||
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
|
|
||||||
if (!value) {
|
|
||||||
return <Placeholder />;
|
|
||||||
}
|
|
||||||
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
function Placeholder({ label = "ergänzen" }: { label?: string }) {
|
|
||||||
return <span className="pd-placeholder">[{label}]</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Open() {
|
|
||||||
return <span className="pd-open">offen</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Logo({ src, className }: { src: string | null; className: string }) {
|
|
||||||
return src ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
|
|
||||||
) : (
|
|
||||||
<span className={`pd-logo-missing ${className}`}>Logo</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetFooter({ project, stand }: { project: ProjectDto; stand: string }) {
|
|
||||||
return (
|
|
||||||
<div className="pd-sheet-footer" aria-hidden="true">
|
|
||||||
<span>{project.name} · Leistungsbilanz</span>
|
|
||||||
<span>
|
|
||||||
Stand {stand} · Datenstand Rev. {project.currentRevision}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TitleBlock({
|
|
||||||
kicker,
|
|
||||||
number,
|
|
||||||
name,
|
|
||||||
rows,
|
|
||||||
project,
|
|
||||||
stand,
|
|
||||||
logoSrc,
|
|
||||||
}: {
|
|
||||||
kicker: string;
|
|
||||||
number?: string | null;
|
|
||||||
name: string;
|
|
||||||
rows: Array<[ReactNode, ReactNode]>;
|
|
||||||
project: ProjectDto;
|
|
||||||
stand: string;
|
|
||||||
logoSrc: string | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="pd-titleblock">
|
|
||||||
<div className="pd-tb-logo">
|
|
||||||
<Logo className="pd-tb-logo-image" src={logoSrc} />
|
|
||||||
</div>
|
|
||||||
<div className="pd-tb-main">
|
|
||||||
<div className="pd-tb-kicker">{kicker}</div>
|
|
||||||
<div className="pd-tb-title">
|
|
||||||
{number ? <span className="pd-tb-number">{number}</span> : null}
|
|
||||||
<span className="pd-tb-name">{name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<dl className="pd-tb-grid">
|
|
||||||
{rows.map(([label, value], index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>{value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<dl className="pd-tb-grid pd-tb-meta">
|
|
||||||
<div>
|
|
||||||
<dt>Projekt</dt>
|
|
||||||
<dd>{project.name}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Projekt-Nr.</dt>
|
|
||||||
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Stand</dt>
|
|
||||||
<dd>{stand}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Datenstand</dt>
|
|
||||||
<dd>Rev. {project.currentRevision}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function boardLabel(entry: PrintBoardHierarchyEntry) {
|
|
||||||
const { board } = entry.summary;
|
|
||||||
return board.boardNumber ? `${board.boardNumber} – ${board.name}` : board.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CoverSheet({
|
|
||||||
project,
|
|
||||||
office,
|
|
||||||
entries,
|
|
||||||
namedSnapshots,
|
|
||||||
logoSrc,
|
|
||||||
stand,
|
|
||||||
}: Omit<PrintDocumentProps, "printedAt"> & { stand: string }) {
|
|
||||||
return (
|
|
||||||
<section className="pd-sheet pd-portrait pd-cover">
|
|
||||||
<div className="pd-cover-head">
|
|
||||||
<div className="pd-cover-kicker">Projektdokumentation Elektrotechnik</div>
|
|
||||||
<Logo className="pd-cover-logo" src={logoSrc} />
|
|
||||||
</div>
|
|
||||||
<div className="pd-cover-title">
|
|
||||||
<h1>Leistungsbilanz</h1>
|
|
||||||
<p>Verteilerübersicht und Verteilerpläne</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="pd-kv">
|
|
||||||
<caption>Projekt</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
|
|
||||||
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
|
|
||||||
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
|
|
||||||
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
|
|
||||||
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
|
|
||||||
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
|
|
||||||
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
|
|
||||||
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="pd-cover-cols">
|
|
||||||
<table className="pd-kv">
|
|
||||||
<caption>Planverfasser</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
|
|
||||||
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
|
|
||||||
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
|
|
||||||
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<table className="pd-kv">
|
|
||||||
<caption>Dokument</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Dokumentart</th><td>Leistungsbilanz</td></tr>
|
|
||||||
<tr><th>Stand</th><td>{stand}</td></tr>
|
|
||||||
<tr><th>Datenstand</th><td>Revision {project.currentRevision}</td></tr>
|
|
||||||
<tr><th>Verteiler</th><td>{entries.length}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="pd-grid pd-block">
|
|
||||||
<caption>Planstände</caption>
|
|
||||||
<thead>
|
|
||||||
<tr><th className="pd-col-index">Index</th><th>Datum</th><th>Änderung</th><th>Bearbeitung</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{namedSnapshots.length ? (
|
|
||||||
namedSnapshots.map((snapshot, index) => (
|
|
||||||
<tr key={snapshot.id}>
|
|
||||||
<td>{String.fromCharCode(97 + (index % 26))}</td>
|
|
||||||
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
|
|
||||||
<td>{snapshot.name}</td>
|
|
||||||
{/* Snapshots do not record an editor yet; the project's
|
|
||||||
current editor is shown for every entry. */}
|
|
||||||
<td><Value value={project.projectEditor} /></td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr><td colSpan={4}>–</td></tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table className="pd-grid pd-block">
|
|
||||||
<caption>Inhalt</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><td className="pd-col-index">1</td><td>Verteilerübersicht und Leistungssummen</td></tr>
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<tr key={entry.summary.board.id}>
|
|
||||||
<td className="pd-col-index">2.{index + 1}</td>
|
|
||||||
<td>Verteilerplan {boardLabel(entry)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<SheetFooter project={project} stand={stand} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StructureNode({
|
|
||||||
entry,
|
|
||||||
entries,
|
|
||||||
}: {
|
|
||||||
entry: PrintBoardHierarchyEntry;
|
|
||||||
entries: PrintBoardHierarchyEntry[];
|
|
||||||
}) {
|
|
||||||
const children = entries.filter(
|
|
||||||
(candidate) => candidate.parentBoardId === entry.summary.board.id
|
|
||||||
);
|
|
||||||
const { board } = entry.summary;
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<div className="pd-node">
|
|
||||||
<strong>{board.boardNumber ?? board.name}</strong>
|
|
||||||
{board.boardNumber ? <span>{board.name}</span> : null}
|
|
||||||
<em>{formatKilowatt(entry.summary.demandPowerKw)} kW</em>
|
|
||||||
</div>
|
|
||||||
{children.length ? (
|
|
||||||
<ul>
|
|
||||||
{children.map((child) => (
|
|
||||||
<StructureNode entries={entries} entry={child} key={child.summary.board.id} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewSheet({
|
|
||||||
project,
|
|
||||||
entries,
|
|
||||||
logoSrc,
|
|
||||||
stand,
|
|
||||||
}: {
|
|
||||||
project: ProjectDto;
|
|
||||||
entries: PrintBoardHierarchyEntry[];
|
|
||||||
logoSrc: string | null;
|
|
||||||
stand: string;
|
|
||||||
}) {
|
|
||||||
const roots = entries.filter((entry) => entry.parentBoardId === null);
|
|
||||||
// Top-level boards whose "fed from" text names no other board are grouped
|
|
||||||
// below that text as an external feed (e.g. "HAK / Zähleranlage").
|
|
||||||
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
|
|
||||||
for (const root of roots) {
|
|
||||||
const feed = root.summary.board.fedFrom ?? "";
|
|
||||||
feedGroups.set(feed, [...(feedGroups.get(feed) ?? []), root]);
|
|
||||||
}
|
|
||||||
const sum =(pick: (entry: PrintBoardHierarchyEntry) => number) =>
|
|
||||||
entries.reduce((total, entry) => total + pick(entry), 0);
|
|
||||||
return (
|
|
||||||
<section className="pd-sheet pd-landscape">
|
|
||||||
<TitleBlock
|
|
||||||
kicker="Übersicht"
|
|
||||||
logoSrc={logoSrc}
|
|
||||||
name="Verteilerübersicht und Leistungssummen"
|
|
||||||
project={project}
|
|
||||||
rows={[
|
|
||||||
["Verteiler", String(entries.length)],
|
|
||||||
["Netz", `${project.threePhaseVoltageV} / ${project.singlePhaseVoltageV} V`],
|
|
||||||
]}
|
|
||||||
stand={stand}
|
|
||||||
/>
|
|
||||||
<div className="pd-overview">
|
|
||||||
<div className="pd-structure">
|
|
||||||
<h2>Versorgungsstruktur</h2>
|
|
||||||
<ul className="pd-tree">
|
|
||||||
{[...feedGroups].map(([feed, feedRoots]) => {
|
|
||||||
const nodes = feedRoots.map((root) => (
|
|
||||||
<StructureNode entries={entries} entry={root} key={root.summary.board.id} />
|
|
||||||
));
|
|
||||||
return feed ? (
|
|
||||||
<li key={`feed:${feed}`}>
|
|
||||||
<div className="pd-node pd-feed">
|
|
||||||
<strong>Einspeisung</strong>
|
|
||||||
<span>{feed}</span>
|
|
||||||
</div>
|
|
||||||
<ul>{nodes}</ul>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
nodes
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<table className="pd-grid pd-plan">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
|
|
||||||
<th className="pd-r">SK</th>
|
|
||||||
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
|
|
||||||
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
|
|
||||||
<th className="pd-r">GZF<br />Vert.</th>
|
|
||||||
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
|
|
||||||
<th className="pd-r">I<sub>B</sub><br />[A]</th>
|
|
||||||
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const { board } = entry.summary;
|
|
||||||
return (
|
|
||||||
<tr key={board.id}>
|
|
||||||
<td style={{ paddingLeft: `${1.6 + entry.depth * 3.5}mm` }}>
|
|
||||||
{entry.depth ? "└ " : ""}
|
|
||||||
<strong>{board.boardNumber ?? "–"}</strong>
|
|
||||||
</td>
|
|
||||||
<td>{board.name}</td>
|
|
||||||
<td>{board.location ?? "–"}</td>
|
|
||||||
<td>{entry.summary.floorName ?? "–"}</td>
|
|
||||||
<td>{board.supplyType ?? "–"}</td>
|
|
||||||
<td>{board.fedFrom ?? "–"}</td>
|
|
||||||
<td className="pd-r">{entry.summary.circuitCount}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
|
|
||||||
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
|
|
||||||
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
|
|
||||||
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
|
|
||||||
<td />
|
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
|
|
||||||
<td />
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
<p className="pd-note">
|
|
||||||
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
|
|
||||||
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
|
|
||||||
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
|
|
||||||
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
|
|
||||||
Verknüpfung und Summierung folgt.
|
|
||||||
</p>
|
|
||||||
<p className="pd-legend">
|
|
||||||
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
|
|
||||||
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
|
|
||||||
S aus cos φ je Gerätezeile
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SheetFooter project={project} stand={stand} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const planColumnCount = 11;
|
|
||||||
|
|
||||||
function roomLabel(row: CircuitTreeDeviceRowDto) {
|
|
||||||
return row.roomNumberSnapshot
|
|
||||||
? `${row.roomNumberSnapshot} ${row.roomNameSnapshot ?? ""}`.trim()
|
|
||||||
: "–";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
|
|
||||||
return (
|
|
||||||
<tr className="pd-component">
|
|
||||||
<td className="pd-bmk">{component.equipmentIdentifier}</td>
|
|
||||||
<td colSpan={6}>{component.name}</td>
|
|
||||||
<td>{formatProtectionDevice(component.protectionDevice) ?? "–"}</td>
|
|
||||||
<td colSpan={3} />
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
|
|
||||||
const rows = circuit.deviceRows;
|
|
||||||
const circuitCells = (
|
|
||||||
<>
|
|
||||||
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
|
|
||||||
<td>{formatCircuitCable(circuit) ?? (circuit.isReserve ? "–" : <Open />)}</td>
|
|
||||||
<td className="pd-r">
|
|
||||||
{circuit.cableLength ?? (circuit.isReserve ? "–" : <Open />)}
|
|
||||||
</td>
|
|
||||||
<td>{circuit.remark ?? ""}</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<tr className="pd-circuit pd-reserve">
|
|
||||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
|
||||||
<td>{circuit.displayName ?? "Reserve"}</td>
|
|
||||||
<td>–</td>
|
|
||||||
<td /><td /><td />
|
|
||||||
<td className="pd-r">–</td>
|
|
||||||
{circuitCells}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (rows.length === 1) {
|
|
||||||
const row = rows[0];
|
|
||||||
const title = circuit.displayName ?? row.displayName;
|
|
||||||
return (
|
|
||||||
<tr className="pd-circuit">
|
|
||||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
|
||||||
<td>
|
|
||||||
{title}
|
|
||||||
{title !== row.displayName ? (
|
|
||||||
<span className="pd-sub"> · {row.displayName}</span>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
<td>{roomLabel(row)}</td>
|
|
||||||
<td className="pd-r">{row.quantity}</td>
|
|
||||||
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
|
|
||||||
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
|
|
||||||
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
|
|
||||||
{circuitCells}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr className="pd-circuit">
|
|
||||||
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
|
|
||||||
<td>{circuit.displayName ?? ""}</td>
|
|
||||||
<td /><td /><td /><td />
|
|
||||||
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
|
|
||||||
{circuitCells}
|
|
||||||
</tr>
|
|
||||||
{rows.map((row) => (
|
|
||||||
<tr className="pd-device" key={row.id}>
|
|
||||||
<td />
|
|
||||||
<td>{row.displayName}</td>
|
|
||||||
<td>{roomLabel(row)}</td>
|
|
||||||
<td className="pd-r">{row.quantity}</td>
|
|
||||||
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
|
|
||||||
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(row.rowTotalPower)}</td>
|
|
||||||
<td colSpan={4} />
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BoardPlanSheet({
|
|
||||||
entry,
|
|
||||||
project,
|
|
||||||
logoSrc,
|
|
||||||
stand,
|
|
||||||
}: {
|
|
||||||
entry: PrintBoardHierarchyEntry;
|
|
||||||
project: ProjectDto;
|
|
||||||
logoSrc: string | null;
|
|
||||||
stand: string;
|
|
||||||
}) {
|
|
||||||
const { summary } = entry;
|
|
||||||
const { board, tree } = summary;
|
|
||||||
return (
|
|
||||||
<section className="pd-sheet pd-landscape">
|
|
||||||
<table className="pd-grid pd-plan">
|
|
||||||
{/* The title block lives in <thead> so it repeats on every printed page. */}
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="pd-tb-cell" colSpan={planColumnCount}>
|
|
||||||
<TitleBlock
|
|
||||||
kicker="Verteilerplan"
|
|
||||||
logoSrc={logoSrc}
|
|
||||||
name={board.name}
|
|
||||||
number={board.boardNumber}
|
|
||||||
project={project}
|
|
||||||
rows={[
|
|
||||||
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
|
|
||||||
["Netzart", board.supplyType ?? "–"],
|
|
||||||
["Gespeist von", board.fedFrom ?? "–"],
|
|
||||||
[
|
|
||||||
<>P<sub>gl</sub></>,
|
|
||||||
<>
|
|
||||||
{formatKilowatt(summary.demandPowerKw)} kW · I<sub>B</sub>{" "}
|
|
||||||
{formatCurrent(summary.designCurrentA)} A
|
|
||||||
</>,
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
stand={stand}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
<tr className="pd-head">
|
|
||||||
<th>SK-Nr.</th><th>Bezeichnung</th><th>Raum</th>
|
|
||||||
<th className="pd-r">Anz.</th>
|
|
||||||
<th className="pd-r">P/Stk.<br />[kW]</th>
|
|
||||||
<th className="pd-r">GZF</th>
|
|
||||||
<th className="pd-r">P<br />[kW]</th>
|
|
||||||
<th>Schutz</th><th>Kabel</th>
|
|
||||||
<th className="pd-r">Länge<br />[m]</th>
|
|
||||||
<th>Bemerkung</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{tree.headerComponents.map((component) => (
|
|
||||||
<ComponentRow component={component} key={component.id} />
|
|
||||||
))}
|
|
||||||
{tree.sections.map((section) => (
|
|
||||||
<SectionRows key={section.id} section={section} />
|
|
||||||
))}
|
|
||||||
{tree.footerComponents.map((component) => (
|
|
||||||
<ComponentRow component={component} key={component.id} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td />
|
|
||||||
<td colSpan={5}>
|
|
||||||
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
|
|
||||||
</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
|
|
||||||
<td colSpan={4} />
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td />
|
|
||||||
<td colSpan={5}>Gleichzeitigkeitsfaktor Verteiler</td>
|
|
||||||
<td className="pd-r">{formatFactor(summary.simultaneityFactor)}</td>
|
|
||||||
<td colSpan={4} />
|
|
||||||
</tr>
|
|
||||||
<tr className="pd-grand">
|
|
||||||
<td />
|
|
||||||
<td colSpan={5}>Gleichzeitige Leistung P<sub>gl</sub></td>
|
|
||||||
<td className="pd-r">{formatKilowatt(summary.demandPowerKw)}</td>
|
|
||||||
<td colSpan={4}>I<sub>B</sub> ≈ {formatCurrent(summary.designCurrentA)} A</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
<SheetFooter project={project} stand={stand} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionRows({
|
|
||||||
section,
|
|
||||||
}: {
|
|
||||||
section: PrintBoardHierarchyEntry["summary"]["tree"]["sections"][number];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<tr className="pd-group">
|
|
||||||
<td className="pd-bmk">{section.prefix}</td>
|
|
||||||
<td colSpan={planColumnCount - 1}>{section.displayName}</td>
|
|
||||||
</tr>
|
|
||||||
{section.components.map((component) => (
|
|
||||||
<ComponentRow component={component} key={component.id} />
|
|
||||||
))}
|
|
||||||
{section.circuits.map((circuit) => (
|
|
||||||
<CircuitRows circuit={circuit} key={circuit.id} />
|
|
||||||
))}
|
|
||||||
<tr className="pd-subtotal">
|
|
||||||
<td />
|
|
||||||
<td colSpan={5}>Summe {section.displayName}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(section.sectionTotalPower)}</td>
|
|
||||||
<td colSpan={4} />
|
|
||||||
</tr>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrintDocument({
|
|
||||||
project,
|
|
||||||
office,
|
|
||||||
entries,
|
|
||||||
namedSnapshots,
|
|
||||||
logoSrc,
|
|
||||||
printedAt,
|
|
||||||
}: PrintDocumentProps) {
|
|
||||||
const stand = dateFormat.format(printedAt);
|
|
||||||
return (
|
|
||||||
<div className="pd-pages">
|
|
||||||
<CoverSheet
|
|
||||||
entries={entries}
|
|
||||||
logoSrc={logoSrc}
|
|
||||||
namedSnapshots={namedSnapshots}
|
|
||||||
office={office}
|
|
||||||
project={project}
|
|
||||||
stand={stand}
|
|
||||||
/>
|
|
||||||
<OverviewSheet entries={entries} logoSrc={logoSrc} project={project} stand={stand} />
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<BoardPlanSheet
|
|
||||||
entry={entry}
|
|
||||||
key={entry.summary.board.id}
|
|
||||||
logoSrc={logoSrc}
|
|
||||||
project={project}
|
|
||||||
stand={stand}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -93,18 +93,9 @@ export interface ProjectDto {
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
siteAddress: string | null;
|
|
||||||
workStage: string | null;
|
|
||||||
projectLead: string | null;
|
|
||||||
projectEditor: string | null;
|
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OfficeProfileDto {
|
|
||||||
officeName: string | null;
|
|
||||||
officeAddress: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectHistoryStateDto {
|
export interface ProjectHistoryStateDto {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
|
|
@ -181,14 +172,7 @@ export interface ProjectCommandResultDto {
|
||||||
history: ProjectHistoryStateDto;
|
history: ProjectHistoryStateDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardInfoDto {
|
export interface DistributionBoardDto {
|
||||||
boardNumber: string | null;
|
|
||||||
location: string | null;
|
|
||||||
fedFrom: string | null;
|
|
||||||
remark: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DistributionBoardDto extends DistributionBoardInfoDto {
|
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -197,13 +181,6 @@ export interface DistributionBoardDto extends DistributionBoardInfoDto {
|
||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentLogoMetadataDto {
|
|
||||||
mimeType: string;
|
|
||||||
fileName: string | null;
|
|
||||||
sizeBytes: number;
|
|
||||||
updatedAtIso: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DistributionBoardCommandResultDto
|
export interface DistributionBoardCommandResultDto
|
||||||
extends ProjectCommandResultDto {
|
extends ProjectCommandResultDto {
|
||||||
distributionBoard: DistributionBoardDto;
|
distributionBoard: DistributionBoardDto;
|
||||||
|
|
@ -226,24 +203,11 @@ export interface CircuitListDto {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildingDto {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectBuildingCommandResultDto
|
|
||||||
extends ProjectCommandResultDto {
|
|
||||||
building: BuildingDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FloorDto {
|
export interface FloorDto {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
buildingId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectFloorCommandResultDto
|
export interface ProjectFloorCommandResultDto
|
||||||
|
|
@ -301,14 +265,8 @@ export interface ProjectDeviceCommandResultDto
|
||||||
projectDevice: ProjectDeviceDto;
|
projectDevice: ProjectDeviceDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateBuildingInput {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateFloorInput {
|
export interface CreateFloorInput {
|
||||||
name: string;
|
name: string;
|
||||||
/** Omitted = keep the current assignment on update; null = no building. */
|
|
||||||
buildingId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateRoomInput {
|
export interface CreateRoomInput {
|
||||||
|
|
@ -535,34 +493,3 @@ export interface CreateCircuitDeviceRowInputDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||||
|
|
||||||
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
|
|
||||||
// The document shapes are shared with the API; see
|
|
||||||
// src/shared/estimate-balance/estimate-balance.ts.
|
|
||||||
import type {
|
|
||||||
EstimateBalanceSnapshot,
|
|
||||||
EstimateComparison,
|
|
||||||
} from "../shared/estimate-balance/estimate-balance";
|
|
||||||
|
|
||||||
export type EstimateBalanceDto = EstimateBalanceSnapshot;
|
|
||||||
export type EstimateComparisonDto = EstimateComparison;
|
|
||||||
|
|
||||||
export type EstimateBalanceTemplate = "kg400" | "empty" | "copy";
|
|
||||||
|
|
||||||
export interface CreateEstimateBalanceInput {
|
|
||||||
name: string;
|
|
||||||
workStage?: string | null;
|
|
||||||
dateIso?: string | null;
|
|
||||||
template: EstimateBalanceTemplate;
|
|
||||||
/** Required for template "copy". */
|
|
||||||
copyFromBalanceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto {
|
|
||||||
balance: EstimateBalanceDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT result: either a new revision or an unchanged document. */
|
|
||||||
export type EstimateBalanceUpdateResultDto =
|
|
||||||
| EstimateBalanceCommandResultDto
|
|
||||||
| { unchanged: true; balance: EstimateBalanceDto };
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue