diff --git a/AGENTS.md b/AGENTS.md index f1a509d..500e9a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,8 +250,11 @@ not change the project revision or undo/redo stacks. Restoring a server-stored snapshot verifies its checksum and the current-state hash, replaces supported project data atomically and records a new `restore` revision with a complete inverse command. Restore can therefore be undone and redone after a restart. -Current snapshot schema version 5 additionally stores the manual quantity share -of every CircuitDeviceRow. Version 4 snapshots are upgraded with +Current snapshot schema version 6 additionally 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. Version 5 stores +the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed external-model source, import batches, room mappings and objects. Version 3 snapshots are upgraded with an empty external-model state; version 2 snapshots @@ -265,7 +268,7 @@ project graph. Project-wide Revit CSV configuration uses `external-csv-configuration.update`. Its complete expected/target snapshot is persisted through the shared project-command transaction, so create, update, -delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable +delete, Undo and Redo are revision-safe. Snapshot schema 5/6, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. The project page exposes the Revit CSV modal. It edits the complete @@ -279,7 +282,7 @@ immutable import batches with original bytes plus the classified cell matrix, normalized source-room mappings and external objects unique by `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. The general external-model repository is read-only; runtime writes belong only -to typed command adapters. Snapshot/restore and portable project transfer schema version 5 +to typed command adapters. Snapshot/restore and portable project transfer schema version 5/6 capture the complete external state and remap every internal UUID and link while preserving IFCGUIDs, source values, original bytes and the classified matrix. Import batches additionally store the monotonic project CSV configuration @@ -368,6 +371,10 @@ Project settings use the persistent `project.update-settings` command. Project metadata, the public-building classification, both voltage defaults and the enabled distribution-board supply types change in one revision and Undo/Redo restores them together; the project PUT route requires `expectedRevision`. +Schema version 3 additionally carries the print cover fields `siteAddress`, +`workStage`, `projectLead` and `projectEditor` (migration `0009`); version 2 +(without them) and baseline version 1 stay executable and keep the current +cover values. The PUT route accepts the cover fields optionally. The public-building flag is the future sizing input for requiring halogen-free cables and lines. The system catalog is `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board @@ -422,6 +429,18 @@ the only supported circuit-protection source; no flat compatibility fields remai Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. +The same command (still schema version 1, additively extended) carries the +board info-card fields `boardNumber`, `location`, `fedFrom` and `remark` +(migration `0008`). They are nullable, trimmed planner values; the PUT route +only includes submitted info fields in the command and its inverse. An empty +board with info values is no longer an unchanged setup, so history deletion of +it is rejected like a changed simultaneity factor. Populated-subtree commands +stored before these fields existed are normalized to `null` values +(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free +text: the logical link to the feeding board/circuit is an open design decision. +These fields are intended to be prefillable from Revit parameters later, but +only through the Phase 14 rules (preview, explicit confirmation, no silent +overwrite). Floor and room setup uses persistent insert, update and delete commands with complete stable snapshots. Their inverses remove or restore only unchanged records and reject floors with assigned rooms/distribution boards or rooms referenced by @@ -443,6 +462,35 @@ Required operations: - synchronization changes +## Print View (Seitenansicht) + +- Route `src/app/projects/[projectId]/print/page.tsx`, opened from the project + page button "Seitenansicht". Read-only: it only uses existing GET endpoints. +- Pure read model `src/frontend/print/print-document-model.ts` (tested in + `tests/print-document-model.test.ts`); React sheets in + `src/frontend/print/print-document.tsx`; styles scoped to `.print-view` in + `src/frontend/print/print-document.css`. +- Pages: cover (A4 portrait, tabular), board overview with structure and + power sums, one plan per board (A4/A3 landscape) whose title block sits in + `` so it repeats on printed pages. PDF export uses the browser print + dialog; page size, margins and "Seite x von y" come from injected named + `@page` rules (`lb-portrait`, `lb-landscape`). +- The board hierarchy and "Σ inkl. unterlagert" in the overview are + PRELIMINARY (text match of `fedFrom` against board number/name, plain sum, + no upstream simultaneity factor). Replace this once the feed link is modeled. +- Colors/fonts are the provisional kbp CD (`--pd-*` variables) until the + official CD specification exists. 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 `0008`) and `document_branding_settings` (migration `0009`), + 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 When explicitly working on Phase 14, use @@ -538,6 +586,9 @@ Users must be able to override sizing suggestions. - full electrical sizing - multi-user/PostgreSQL operation - supported production deployment +- logical feed link between distribution boards (`fedFrom` stays free text + until the project owner decides whether it references a board, a feeding + circuit or both) and the resulting hierarchical power summation Do not implement these while working on an unrelated phase. diff --git a/docs/README.md b/docs/README.md index 69a2115..395b1ac 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we fachliches Modell und Grid-Aufteilung - [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur, Drag-and-drop, Sortierung, Filterung und Undo/Redo +- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt, + Verteilerübersicht, Verteilerpläne, Büro-Logo - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – bewusst noch nicht implementierte oder begrenzte Funktionen diff --git a/docs/cable-sizing-module.md b/docs/cable-sizing-module.md new file mode 100644 index 0000000..80b7cc8 --- /dev/null +++ b/docs/cable-sizing-module.md @@ -0,0 +1,243 @@ +# Cable Sizing Module (Proposal) + +**Status: proposal, not yet reviewed or merged by the project owner.** This +document and the accompanying `src/cable-sizing/`, `src/db/schema/cable-sizing-calculations.ts`, +`src/server/{controllers,routes}/cable-sizing.*` and `src/frontend/components/cable-sizing-*` +files were written by a third party (see git history/authorship) against the +project's own stated direction in `docs/spec/06-future-sizing-and-calculations.md` +("The app should later support rule-based protection and cable sizing") and +`AGENTS.md` ("... and later electrical sizing logic"). Nothing here has been +pushed to the project's own repository; it exists as a local branch for +review, testing and discussion. + +## Why a separate module instead of extending core domain code + +`AGENTS.md` is explicit that critical multi-write commands, the revision/undo +system and the Circuit-First domain model are the supported architecture, and +that changes should be small and reviewable. A cable-sizing calculation is +**not** a project mutation - it doesn't need `expectedRevision`, doesn't +belong in the undo/redo stack, and shouldn't grow the `circuit.update` +command's `switch` statement or the DTOs it doesn't already have. + +So this module is built exactly like the existing `src/external-model/` +adapter (the Revit/CSV import foundation): a one-way dependency boundary. + +``` +src/cable-sizing/domain/ <- pure functions and types, zero imports from + db/, server/ or frontend/ +src/db/schema/ <- one new, fully additive table +src/db/repositories/ <- one plain repository (no revision semantics) +src/server/{controllers,routes}/cable-sizing.* <- one new, isolated route + group, mounted with a single app.use() line +src/frontend/components/cable-sizing-* <- one new modal + one small + API client, following the existing + FormModal / CircuitProtectionModal pattern +``` + +Nothing outside these files imports from them except the two required +one-line hooks described below. If this module is rejected or needs to move +out again, removing it is a matter of deleting these files, the two +one-line hooks, and running a `DROP TABLE` migration - it never touches +`circuits`, `project_revisions` or any command/history table. + +## The two required hooks into existing code + +1. `src/server/index.ts`: one import + one `app.use("/api/cable-sizing", cableSizingRouter)` + line, next to the existing `app.use("/api/projects", ...)` etc. +2. `src/frontend/components/circuit-tree-editor.tsx`: one new `useState` for + the open modal, one new trigger condition (mirroring the existing + `isProtectionTrigger` / `protectionEditorCircuit` pattern almost exactly), + and one new conditionally-rendered `` alongside the + existing ``. See the diff for the exact lines. + +No changes to `circuit-project-command.model.ts`, `project-command.service.ts`, +any Zod command schema, any migration for `circuits`/`project_revisions`, or +any existing test. + +## Coupling to the grid UI (the one fragile point) + +Everything described above - the calculation, the audit table, applying a +result via `circuit.update` - depends only on stable, explicitly documented +domain fields (`cableType`/`cableCrossSection`/`cableLength`/ +`circuitTotalPower`/`voltage`/`protectionDevice.ratedCurrentA`), the same +ones `AGENTS.md` already treats as protected ("Protection and cable data +belong to the circuit"). None of that breaks if the grid UI changes. + +The one place that *is* coupled to a UI implementation detail: the click +trigger in `circuit-tree-editor.tsx` matches grid **column keys** +(`cableSummary` and `cableCrossSection`, defined in +`circuit-grid-model.ts`) to decide where to show the calculator icon. +Column visibility and order in this app are a per-browser user preference, +not fixed - so this is checked against both known cable-related columns to +reduce (not eliminate) the chance a user's personal column layout hides the +trigger entirely. If a future column rename removes both keys, the failure +mode is purely cosmetic: the calculator icon/click stops appearing on that +cell, nothing crashes, no data is affected, and the rest of the app +(including manual cable-field editing) is completely unaffected - the +module's actual logic and persistence never depend on this column key. + +## Feature overview + +- **Calculate a recommendation**: laying method, insulation, conductor + material, ambient temperature, grouping, cos phi and max voltage drop - + same VDE 0298-4 reference-method model as the sibling Kabelliste tool + (see Verification status below). +- **Breaker-aware sizing**: if the circuit already has a protection device + (`circuit.protectionDevice`), the cross-section is selected against + `designCurrentA = max(operatingCurrentA, protectionDevice.ratedCurrentA)`, + not the load current alone - the standard `In <= Iz` rule (a breaker only + trips at its own rated current, so a cable sized for the actual load + alone could overheat under sustained current below that threshold). If no + cross-section can cover an oversized breaker, that is reported as the + limiting factor by name, not a generic capacity error. +- **Maximum length for the voltage-drop limit**: each cross-section option + also reports the longest single run that still meets the requested + `maxVoltageDropPercent` at the given load - the inverse of the + voltage-drop check, shown for the recommended cross-section. +- **Practical minimum for socket circuits**: `single_phase`-category + circuits are raised to at least 2.5 mm² if the calculation alone would + recommend less - see the dedicated note under Verification status. Never + lowers a calculation that already needs more. +- **Manual entry, always available**: the modal has a plain text Kabeltyp/ + Querschnitt field. Running a calculation pre-fills it as a suggestion, + but the field is the single source of truth for what gets applied, and + stays freely editable - satisfying + `docs/spec/06-future-sizing-and-calculations.md`'s explicit requirement + that "users must remain able to manually override suggestions" without + requiring a calculation to have been run first. A non-blocking warning + appears if the typed value is not a standard cross-section, is smaller + than the last calculation, or is below the practical minimum above - + informational only, never forced, matching the same spec doc's "shown as + a warning or status indicator, not as an automatic forced change." + +## How a suggestion or manual entry is applied + +The module never writes to `circuits` directly. The modal's "Übernehmen" +button calls the **existing, unmodified** frontend helper with whatever is +currently in the Kabeltyp/Querschnitt fields, calculated or hand-typed: + +```ts +updateCircuitById(projectId, expectedRevision, circuitId, { + cableCrossSection: "4 mm²", + cableType: "NYM-J 3x2.5", + cableLength: 30, +}); +``` + +which is the same `circuit.update` command the grid already uses for manual +cell edits. This means: + +- optimistic concurrency (`expectedRevision`) is respected automatically +- the change appears in the project's revision history and is undoable/ + redoable exactly like a manual edit +- nothing is ever written automatically - calculating only fills the modal's + own fields, applying is always an explicit, separate click + +## API contract + +`POST /api/cable-sizing/calculate` + +```jsonc +{ + "phase": 1, // 1 | 3 + "mode": "power", // "power" | "current" + "powerKw": 4.2, // circuit.circuitTotalPower, when mode="power" + "cosPhi": 1, + "voltage": 230, // circuit.voltage (already project-derived, read-only) + "lengthM": 23.5, // circuit.cableLength + "layingMethod": "C", // DIN VDE 0298-4 reference method, A1|A2|B1|B2|C|D1|D2|E|F|G + "conductorMaterial": "copper", // "copper" | "aluminum" + "insulation": "pvc", // "pvc" | "xlpe" + "ambientTemperatureC": 30, + "groupingCircuits": 1, + "maxVoltageDropPercent": 3, + "harmonicNeutralLoad": "none", // "none" | "15to33Percent" | "over33Percent", three-phase only + "existingProtectionRatedCurrentA": 16, // optional, from circuit.protectionDevice + "circuitCategory": "single_phase", // optional, from the circuit's section.category - + // enables the practical-minimum convention below + "context": { "projectId": "...", "circuitId": "...", "equipmentIdentifier": "-1F1.1" } +} +``` + +Response: `{ calculationId, result: CableSizingResult, alerts: CableSizingAlert[] }` +(see `src/cable-sizing/domain/cable-sizing-calculation.ts` for the exact +shape). `GET /api/cable-sizing/laying-methods` and `.../insulation-materials` +expose the pick-list metadata (including which combinations are verified) so +a future non-modal UI could build its own form without hard-coding the enum. + +## Verification status - important + +The reference current-carrying-capacity tables are ported from a sibling +project's Kabelliste module, which was itself only verified against ~8 +public sources for **six** of the ten DIN VDE 0298-4 reference laying +methods (A1, B2, C, E, D1, D2) and **PVC insulation only**. The other four +methods (A2, B1, F, G) and XLPE/VPE insulation could not be verified without +contradiction across sources during that earlier work, so +`calculateCableSizing` deliberately returns `dataVerified: false` and no +numeric recommendation for those combinations, rather than a guessed value. +The UI surfaces this as a plain critical alert. Anyone with access to the +actual norm text can extend `LAYING_METHOD_VERIFIED` and +`CURRENT_CAPACITY_A` in `src/cable-sizing/domain/cable-sizing-calculation.ts` +once the missing tables are confirmed. + +The protection-coordination check (`existingProtectionRatedCurrentA` vs. the +recommended cross-section's corrected capacity) is a **simplified** `In <= +Iz` check only - it is not a full IEC 60364-4-43 overload (`I2 <= 1.45 x +Iz`) or short-circuit withstand check. + +`PRACTICAL_MINIMUM_CROSS_SECTION_MM2` (2.5 mm² for `single_phase`) is +**not** a verified norm value at all - it is a named planning convention, +sourced directly from this project's own +`docs/spec/06-future-sizing-and-calculations.md` ("Standard Single-Phase +Circuits ... usually use ... cable cross-section: 2.5 mm²"), applied as a +floor on top of the calculated recommendation. It intentionally only +covers the one category and one convention that document already states; +it is not a general substitute for norm-compliant calculation. + +## Relationship to the project's own future-sizing spec + +`docs/spec/06-future-sizing-and-calculations.md` separately describes simple +category-based defaults (e.g. lighting circuits -> 10 A / 1.5 mm²) as +"common planning defaults, not a replacement for full norm-compliant +calculation". This module is the latter: an on-demand, norm-referenced +calculation for one circuit at a time, not a bulk default-filling tool. The +two are complementary and could later be wired together (e.g. the category +defaults pre-fill this module's laying-method/insulation fields), but that +integration is out of scope here. + +The spec's `isPublicBuilding` halogen-free rule and the `Control Requirement` +(DALI/KNX/core-count) field are **not** implemented by this module - both +concern cable *type* selection, not cross-section sizing, and the latter +isn't in the current schema yet. They're natural follow-ups once this module +is reviewed. + +## Maintenance plan + +The `src/cable-sizing/domain/` folder has zero imports from the rest of the +app by design (see the dependency-direction note above), so it can be +extracted into an independently versioned/published package later (e.g. a +private npm package or a git subtree) without touching anything outside the +five files/folders listed above - "separately maintained but part of the +app for now", per the intent of this proposal. + +## Local testing + +Deployed and manually exercised end-to-end on an internal test host at +`http://192.168.0.133:3220` (own docker deployment, own subnet/ports to +avoid clashing with ~50 other containers on that host - see the two +commits marked "Local-only" in this branch, which are not part of this +proposal and should not be carried over if it is ever proposed upstream). +Exercised against seeded real project/circuit data created through the +actual command API (not direct DB writes), including a deliberately long +(85 m) circuit to trigger the voltage-drop-critical path. + +`npm test`, `npm run build:api`, `npm run build:web` and +`npm run typecheck:scripts` all pass with this module included at every +commit in this branch. + +This branch (`feature/cable-sizing-module`) is pushed to a mirror of this +repository under this homelab's own Forgejo instance, not to +`git.jappel.io` - no write access to the upstream repository was available +or used. See the branch's commit history for the incremental history of +this module, including fixes made during manual testing (trigger column, +icon rendering, breaker-aware sizing). diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 9ed9e0a..b1d7f07 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -74,8 +74,9 @@ Protected-board structure uses persisted inverse of a delete. The dispatcher also supports the documented project-device, project settings, distribution-board and project-location commands. The generic editor commands listed here use schema version `1`. -`project.update-settings` uses version `2` and still accepts supported baseline -version `1` commands without changing the newer building classification. +`project.update-settings` uses version `3` (including the print cover fields) +and still accepts version `2` and supported baseline version `1` commands +without changing the newer cover fields or building classification. Pre-baseline formats remain unsupported. Unsupported types or schema versions are rejected. Insert commands contain the complete entity or circuit block with stable ids; delete commands include the expected parent identity. Circuit @@ -173,6 +174,9 @@ synchronizes linked circuit rows implicitly. - `PUT /projects/:projectId` - request: `{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }` + - optional print cover fields `siteAddress` (max. 500), `workStage` (max. + 100), `projectLead` and `projectEditor` (max. 200 each); omitted fields keep + their value, empty strings are stored as `null` - executes `project.update-settings` as one atomic revision - response: `{ "project": { ... }, "revision": { ... }, "history": { ... } }` @@ -230,9 +234,13 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. - stale revisions return `409 PROJECT_REVISION_CONFLICT` - `PUT /projects/:projectId/distribution-boards/:distributionBoardId` - body: - `{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }` - - executes `distribution-board.update`; floor and supply type are restored - together by persistent Undo/Redo + `{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }` + - optional info-card fields: `boardNumber` (max. 50), `location`, + `fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed + and an empty string is stored as `null` + - executes `distribution-board.update`; all submitted values, including the + submitted info fields, are restored together by persistent Undo/Redo + - `fedFrom` is free text until the logical feed link is modeled - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - duplicates the complete distribution-board subtree with new owning UUIDs diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 4f7f660..3e70e76 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -27,3 +27,12 @@ - Protection and cable values are planner-owned manual selections. The later sizing/warning engine does not yet validate them against load, length or voltage-drop rules. +- Distribution-board `fedFrom` is free text. The print overview derives its + supply structure and "Σ inkl. unterlagert" only from exact text matches with + board numbers/names and adds values without an upstream simultaneity factor + (see `docs/print-view.md`). +- The print view uses a kbp corporate design derived from logo and + presentation material. Snapshots do not record an editor, so the cover's + Planstände list shows the project's current editor for every entry. On screen, long + board plans are not split into pages; pagination happens in the browser print + dialog. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 92efc98..5230b74 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -232,8 +232,9 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. `project.update-settings` versioniert Projektname, interne und externe -Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches -Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten +Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift, +Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration +`0009`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens @@ -416,7 +417,14 @@ wieder her. verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder +Derselbe Befehl versioniert die Felder der Verteiler-Infokarte +(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0008`). 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 6 enthält diese Felder und die Deckblattangaben des +Projekts; ältere Versionen werden mit `null` +hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und enthält bereits den vollständigen externen Modellzustand aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die diff --git a/docs/print-view.md b/docs/print-view.md new file mode 100644 index 0000000..202f172 --- /dev/null +++ b/docs/print-view.md @@ -0,0 +1,106 @@ +# Seitenansicht und PDF-Export + +Die Seitenansicht ist rein lesend und verändert keine Projektdaten. + +## Aufruf + +- Projektseite → Button **Seitenansicht** öffnet + `/projects/:projectId/print` in einem neuen Tab. +- **Als PDF speichern / Drucken** nutzt den Druckdialog des Browsers + (Ziel „Als PDF speichern“). Der Dokumenttitel `Leistungsbilanz – ` + wird als Dateiname vorgeschlagen. +- **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um. + Deckblatt bleibt A4 hoch. +- **Büroangaben** pflegt Büroname und Büroanschrift (Planverfasser, für alle + Projekte). +- **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo. + +## Seiten + +1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument, + Planstände (aus benannten Sicherungspunkten) und Inhalt. Leere Angaben + erscheinen als orangefarbenes „[ergänzen]“. +2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je + Verteiler mit Stromkreisanzahl, Pinst (Anzahl × Leistung je + Stück), PGeräte (mit Geräte-GZF), Verteiler-GZF, + Pgl, IB und „Σ inkl. unterlagert“. +3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit + Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und + Pgl; darunter Kopfkomponenten, Gruppen, Stromkreise + (mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen), + Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“ + markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim + Drucken auf Folgeseiten. + +Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten +`@page`-Regeln (`lb-portrait`, `lb-landscape`), die die Seite zur Laufzeit +einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte +Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken. + +## Vorläufige Punkte + +- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet + einen Verteiler nur dann unter, wenn der Text genau der Nummer oder + Bezeichnung eines anderen Verteilers entspricht; „Σ inkl. unterlagert“ ist + eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung + (Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind + offen. +- **IB** ist ein symmetrischer Drehstromwert + S / (√3 · U3~) mit S aus P / cos φ je Gerätezeile + (fehlendes cos φ = 1) und Verteiler-GZF. +- **Corporate Design:** Farben (`--pd-*` in + `src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo + und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle + CD-Vorgabe ersetzt, falls diese abweicht. +- **Planstände:** Die Spalte „Bearbeitung“ zeigt für jeden Planstand die + aktuelle Bearbeitung aus den Projekteinstellungen, weil Sicherungspunkte + (noch) keinen Bearbeiter speichern. + +## Datenquellen des Deckblatts + +| Feld | Quelle | +|---|---| +| Bauvorhaben, Bauherr, Projektnummern, Beschreibung | Projekteinstellungen | +| Anschrift Bauvorhaben, Leistungsphase, Projektleitung, Bearbeitung | Projekteinstellungen → Abschnitt „Deckblatt“ (`siteAddress`, `workStage`, `projectLead`, `projectEditor`) | +| Büro, Anschrift Büro | Seitenansicht → „Büroangaben“ (global) | +| Stand, Datenstand, Anzahl Verteiler, Inhalt | automatisch | +| Planstände | benannte Sicherungspunkte | + +Die vier Projektfelder laufen über `project.update-settings` (Schema-Version +3, Migration `0009`) 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 `0009`), + 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 `0008`). +- `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 diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 8680f59..8d27b1d 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -12,6 +12,8 @@ const requiredTables = [ "circuit_protection_devices", "distribution_board_components", "distribution_board_component_protection_devices", + "document_branding_assets", + "document_branding_settings", ]; const tablePlaceholders = requiredTables.map(() => "?").join(", "); @@ -87,6 +89,10 @@ const requiredProjectColumns = [ "description", "is_public_building", "enabled_distribution_board_supply_types", + "site_address", + "work_stage", + "project_lead", + "project_editor", ]; const projectColumns = new Set( db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name) @@ -98,6 +104,10 @@ const requiredDistributionBoardColumns = [ "floor_id", "supply_type", "simultaneity_factor", + "board_number", + "location", + "fed_from", + "remark", ]; const distributionBoardColumns = new Set( db diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..88e1c35 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1209,6 +1209,48 @@ a.kpi:hover { box-shadow: inset 0 0 0 1px var(--color-signal); } +.tree-grid .cell-cable-sizing-trigger { + color: var(--color-primary); + cursor: pointer; + font-weight: 600; + transition: + background-color 0.12s ease, + box-shadow 0.12s ease; +} + +:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger { + color: var(--color-accent-pale); +} + +.tree-grid .cell-cable-sizing-trigger:hover { + background: rgba(63, 130, 166, 0.15); + box-shadow: inset 0 0 0 1px var(--color-primary); +} + +.tree-grid .cell-cable-sizing-trigger { + position: relative; + padding-left: 1.4em; +} + +.tree-grid .cell-cable-sizing-trigger::before { + content: ""; + position: absolute; + left: 0.2em; + top: 50%; + transform: translateY(-50%); + width: 0.9em; + height: 0.9em; + opacity: 0.85; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233f82a6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E"); +} + +:root[data-bs-theme="dark"] .tree-grid .cell-cable-sizing-trigger::before { + background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a9d2e6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='2' width='16' height='20' rx='2'/%3E%3Cline x1='8' y1='6' x2='16' y2='6'/%3E%3Cline x1='8' y1='10' x2='8' y2='10.01'/%3E%3Cline x1='12' y1='10' x2='12' y2='10.01'/%3E%3Cline x1='16' y1='10' x2='16' y2='10.01'/%3E%3Cline x1='8' y1='14' x2='8' y2='14.01'/%3E%3Cline x1='12' y1='14' x2='12' y2='14.01'/%3E%3Cline x1='16' y1='14' x2='16' y2='14.01'/%3E%3Cline x1='8' y1='18' x2='8' y2='18.01'/%3E%3Cline x1='12' y1='18' x2='12' y2='18.01'/%3E%3Cline x1='16' y1='18' x2='16' y2='18.01'/%3E%3C/svg%3E"); +} + .tree-grid .device-drag-handle { cursor: grab; } diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index fa342ea..73f9460 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -54,6 +54,12 @@ import { type DistributionBoardSupplyType, } from "../../../shared/constants/distribution-board"; import { ProjectVersionHistory } from "../../../frontend/components/project-version-history"; +import { + DistributionBoardInfoCard, + fromDistributionBoardInfoDraft, + toDistributionBoardInfoDraft, + type DistributionBoardInfoDraft, +} from "../../../frontend/components/distribution-board-info-card"; import { ProjectSettingsModal, type ProjectSettingsInput, @@ -101,6 +107,15 @@ export default function ProjectDetailPage() { setEditingBoardSimultaneityFactor, ] = useState("1"); const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); + const [editingBoardInfo, setEditingBoardInfo] = + useState( + toDistributionBoardInfoDraft({ + boardNumber: null, + location: null, + fedFrom: null, + remark: null, + }) + ); const [floorName, setFloorName] = useState(""); const [editingFloor, setEditingFloor] = useState(null); const [editingRoom, setEditingRoom] = useState(null); @@ -424,6 +439,7 @@ export default function ProjectDetailPage() { String(board.simultaneityFactor) ); setEditingBoardCopyName(`${board.name} Kopie`); + setEditingBoardInfo(toDistributionBoardInfoDraft(board)); } function openBoardCreator() { @@ -459,6 +475,7 @@ export default function ProjectDetailPage() { floorId: editingBoardFloorId || null, supplyType: editingBoardSupplyType, simultaneityFactor, + ...fromDistributionBoardInfoDraft(editingBoardInfo), }, project.currentRevision ); @@ -849,6 +866,13 @@ export default function ProjectDetailPage() {

+ + Seitenansicht +
+
+ +

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