From 4779e4a1a7aa6e326e288defb603559a9914c9ad Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Fri, 25 Sep 2026 19:31:04 +0200 Subject: [PATCH] Add print view, board info card and document logo upload - Seitenansicht (/projects/:projectId/print): tabular cover page, board overview with supply structure and power sums, one plan per board with a repeating title block; PDF via the browser print dialog with named @page rules (A4 portrait cover, A4/A3 landscape plans, page x of y). - Distribution-board info card (board number, location, fed-from, remark) in the board edit dialog, persisted through the additively extended distribution-board.update command (migration 0008, snapshot schema 6, legacy subtree commands normalized). - Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project revisions and snapshots. - fed-from stays free text; the overview hierarchy and cumulated sums are preliminary until the feed link is modeled. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 55 +- docs/README.md | 2 + docs/circuit-list-editor-api.md | 30 +- docs/circuit-list-editor-known-limitations.md | 8 + docs/current-architecture.md | 8 +- docs/print-view.md | 80 + scripts/db-verify-circuit-schema.js | 5 + src/app/projects/[projectId]/page.tsx | 38 +- src/app/projects/[projectId]/print/page.tsx | 261 ++ .../0007_board_info_and_document_logo.sql | 12 + src/db/migrations/meta/0007_snapshot.json | 2493 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + ...rd-structure-project-command.repository.ts | 5 + ...oard-subtree-project-command.repository.ts | 18 +- .../document-branding.repository.ts | 52 + .../project-snapshot.repository.ts | 3 +- src/db/schema/distribution-boards.ts | 4 + src/db/schema/document-branding-assets.ts | 18 + ...istribution-board-project-command.model.ts | 40 +- ...stribution-board-subtree-snapshot.model.ts | 25 +- .../models/project-state-snapshot.model.ts | 97 +- .../distribution-board-info-card.tsx | 110 + src/frontend/print/print-document-model.ts | 180 ++ src/frontend/print/print-document.css | 473 ++++ src/frontend/print/print-document.tsx | 612 ++++ src/frontend/types.ts | 16 +- src/frontend/utils/api.ts | 32 +- .../composition/application-repositories.ts | 2 + .../distribution-board.controller.ts | 13 + .../document-branding.controller.ts | 84 + src/server/index.ts | 2 + src/server/routes/document-branding.routes.ts | 9 + src/shared/constants/distribution-board.ts | 41 + .../validation/document-branding.schemas.ts | 25 + .../validation/project-structure.schemas.ts | 21 +- ...ructure-project-command.repository.test.ts | 104 + ...subtree-project-command.repository.test.ts | 61 + tests/print-document-model.test.ts | 176 ++ tests/project-snapshot.repository.test.ts | 61 + tests/project-state-snapshot.test.ts | 47 + 40 files changed, 5297 insertions(+), 33 deletions(-) create mode 100644 docs/print-view.md create mode 100644 src/app/projects/[projectId]/print/page.tsx create mode 100644 src/db/migrations/0007_board_info_and_document_logo.sql create mode 100644 src/db/migrations/meta/0007_snapshot.json create mode 100644 src/db/repositories/document-branding.repository.ts create mode 100644 src/db/schema/document-branding-assets.ts create mode 100644 src/frontend/components/distribution-board-info-card.tsx create mode 100644 src/frontend/print/print-document-model.ts create mode 100644 src/frontend/print/print-document.css create mode 100644 src/frontend/print/print-document.tsx create mode 100644 src/server/controllers/document-branding.controller.ts create mode 100644 src/server/routes/document-branding.routes.ts create mode 100644 src/shared/validation/document-branding.schemas.ts create mode 100644 tests/print-document-model.test.ts diff --git a/AGENTS.md b/AGENTS.md index f1a509d..98c6a28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,8 +250,12 @@ 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`; all older +supported versions are upgraded with `null` for these fields. Stored named and +automatic snapshots of every supported version (1 to 6) remain restorable; +they are upgraded while parsing. Version 5 stores +the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed external-model source, import batches, room mappings and objects. Version 3 snapshots are upgraded with an empty external-model state; version 2 snapshots @@ -265,7 +269,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 +283,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 @@ -422,6 +426,18 @@ the only supported circuit-protection source; no flat compatibility fields remai Distribution-board floor assignment and a project-enabled supply type use `distribution-board.update`; both values are snapshot/export fields and one persistent undo step. +The same command (still schema version 1, additively extended) carries the +board info-card fields `boardNumber`, `location`, `fedFrom` and `remark` +(migration `0007`). They are nullable, trimmed planner values; the PUT route +only includes submitted info fields in the command and its inverse. An empty +board with info values is no longer an unchanged setup, so history deletion of +it is rejected like a changed simultaneity factor. Populated-subtree commands +stored before these fields existed are normalized to `null` values +(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free +text: the logical link to the feeding board/circuit is an open design decision. +These fields are intended to be prefillable from Revit parameters later, but +only through the Phase 14 rules (preview, explicit confirmation, no silent +overwrite). Floor and room setup uses persistent insert, update and delete commands with complete stable snapshots. Their inverses remove or restore only unchanged records and reject floors with assigned rooms/distribution boards or rooms referenced by @@ -443,6 +459,32 @@ 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. Cover fields shown as "[ergänzen]" + (address, Leistungsphase, office staff) have no data model yet. +- The document logo is global office branding: table + `document_branding_assets` (migration `0007`), endpoints + `GET|PUT|DELETE /api/document-branding/logo` 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 +580,11 @@ 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 +- data model for the remaining print cover fields (address, Leistungsphase, + project lead/editor) and the official kbp CD values 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/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 9ed9e0a..762a279 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -230,9 +230,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 @@ -425,3 +429,23 @@ project device never triggers synchronization implicitly. Parser errors expose stable codes such as `header-not-found`, `ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed import uploads and parses the file again and verifies its hash. + +## Document Branding (print view) + +Office-wide branding for the print view. These endpoints are mounted at +`/api/document-branding`, outside `/api/projects`, because the data is global +and intentionally not part of project revisions, snapshots or transfer. + +- `GET /document-branding` + - returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null }` +- `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` diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 4f7f660..bea8b87 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -27,3 +27,11 @@ - 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 cover page shows placeholders for address, Leistungsphase and + office staff, and uses a provisional kbp corporate design. 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..cbea92f 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -416,7 +416,13 @@ wieder her. verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. -Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder +Derselbe Befehl versioniert die Felder der Verteiler-Infokarte +(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die +Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale +Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und +ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. +Snapshot-Schema 6 enthält diese Felder; ä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..3de19a5 --- /dev/null +++ b/docs/print-view.md @@ -0,0 +1,80 @@ +# Seitenansicht und PDF-Export + +Stand: erste Ausbaustufe (Beispiel zur Abstimmung). 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. +- **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. Angaben ohne + Datenmodell erscheinen als orangefarbenes „[ergänzen]“ (Anschrift, + Leistungsphase, Projektleitung, Bearbeitung). +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. Der Büroname steht vorläufig als Konstante + `printOfficeName` in `src/frontend/print/print-document.tsx`. + +## 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 diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 8680f59..558178e 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -12,6 +12,7 @@ const requiredTables = [ "circuit_protection_devices", "distribution_board_components", "distribution_board_component_protection_devices", + "document_branding_assets", ]; const tablePlaceholders = requiredTables.map(() => "?").join(", "); @@ -98,6 +99,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/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 [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); + 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 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 + +
+ {data ? ( + + ) : error ? null : ( +

Seitenansicht wird geladen …

+ )} +
+ ); +} diff --git a/src/db/migrations/0007_board_info_and_document_logo.sql b/src/db/migrations/0007_board_info_and_document_logo.sql new file mode 100644 index 0000000..5f27040 --- /dev/null +++ b/src/db/migrations/0007_board_info_and_document_logo.sql @@ -0,0 +1,12 @@ +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; \ No newline at end of file diff --git a/src/db/migrations/meta/0007_snapshot.json b/src/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..d0c04de --- /dev/null +++ b/src/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,2493 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4db6e93a-3070-459a-94ef-587fd4929411", + "prevId": "b25e1a40-c400-4848-bc24-a04bda7a8209", + "tables": { + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index eedfc7a..2b5f1f5 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1786043080323, "tag": "0006_damp_skrulls", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1790411540872, + "tag": "0007_board_info_and_document_logo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/distribution-board-structure-project-command.repository.ts b/src/db/repositories/distribution-board-structure-project-command.repository.ts index 29a8f21..db2cede 100644 --- a/src/db/repositories/distribution-board-structure-project-command.repository.ts +++ b/src/db/repositories/distribution-board-structure-project-command.repository.ts @@ -388,10 +388,15 @@ function structureMatches( ) { const { simultaneityFactor, + boardNumber, + location, + fedFrom, + remark, ...actualDistributionBoard } = actual.board; if ( simultaneityFactor !== 1 || + [boardNumber, location, fedFrom, remark].some((value) => value !== null) || !sameRecord( expected.distributionBoard, actualDistributionBoard diff --git a/src/db/repositories/distribution-board-subtree-project-command.repository.ts b/src/db/repositories/distribution-board-subtree-project-command.repository.ts index 63038eb..6788dcc 100644 --- a/src/db/repositories/distribution-board-subtree-project-command.repository.ts +++ b/src/db/repositories/distribution-board-subtree-project-command.repository.ts @@ -10,6 +10,7 @@ import { } from "../../domain/models/distribution-board-subtree-project-command.model.js"; import { assertDistributionBoardSubtreeSnapshot, + normalizeDistributionBoardSubtreeSnapshot, type DistributionBoardSubtreeSnapshot, } from "../../domain/models/distribution-board-subtree-snapshot.model.js"; import type { @@ -63,19 +64,20 @@ export class DistributionBoardSubtreeProjectCommandRepository projectId: string, command: DistributionBoardSubtreeProjectCommand ) { + const snapshot = normalizeDistributionBoardSubtreeSnapshot( + command.payload.snapshot + ); if (command.type === distributionBoardInsertSubtreeCommandType) { - this.insert(database, projectId, command.payload.snapshot); - return createDistributionBoardDeleteSubtreeProjectCommand( - command.payload.snapshot - ); + this.insert(database, projectId, snapshot); + return createDistributionBoardDeleteSubtreeProjectCommand(snapshot); } if (command.type === distributionBoardDeleteSubtreeCommandType) { const current = this.captureFrom( database, projectId, - command.payload.snapshot.distributionBoard.id + snapshot.distributionBoard.id ); - if (!sameSnapshot(current, command.payload.snapshot)) { + if (!sameSnapshot(current, snapshot)) { throw new Error( "Distribution-board subtree changed before deletion." ); @@ -97,9 +99,7 @@ export class DistributionBoardSubtreeProjectCommandRepository "Distribution-board subtree could not be deleted." ); } - return createDistributionBoardInsertSubtreeProjectCommand( - command.payload.snapshot - ); + return createDistributionBoardInsertSubtreeProjectCommand(snapshot); } throw new Error("Unsupported distribution-board subtree command."); } diff --git a/src/db/repositories/document-branding.repository.ts b/src/db/repositories/document-branding.repository.ts new file mode 100644 index 0000000..450e8d2 --- /dev/null +++ b/src/db/repositories/document-branding.repository.ts @@ -0,0 +1,52 @@ +import { eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { + documentBrandingAssets, + type DocumentBrandingAsset, +} from "../schema/document-branding-assets.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 + ); + } +} diff --git a/src/db/repositories/project-snapshot.repository.ts b/src/db/repositories/project-snapshot.repository.ts index 5274fee..7a7522a 100644 --- a/src/db/repositories/project-snapshot.repository.ts +++ b/src/db/repositories/project-snapshot.repository.ts @@ -5,6 +5,7 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js"; import { deserializeProjectStateSnapshot, + isSupportedProjectStateSnapshotSchemaVersion, projectStateSnapshotSchemaVersion, } from "../../domain/models/project-state-snapshot.model.js"; import type { @@ -138,7 +139,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore { if (actualChecksum !== stored.payloadSha256) { throw new Error("Project snapshot checksum verification failed."); } - if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) { + if (!isSupportedProjectStateSnapshotSchemaVersion(stored.schemaVersion)) { throw new Error("Project snapshot schema version is not supported."); } const targetState = deserializeProjectStateSnapshot(stored.payloadJson); diff --git a/src/db/schema/distribution-boards.ts b/src/db/schema/distribution-boards.ts index 06fdaf6..eb63486 100644 --- a/src/db/schema/distribution-boards.ts +++ b/src/db/schema/distribution-boards.ts @@ -16,5 +16,9 @@ export const distributionBoards = sqliteTable("distribution_boards", { simultaneityFactor: real("simultaneity_factor") .notNull() .default(1), + boardNumber: text("board_number"), + location: text("location"), + fedFrom: text("fed_from"), + remark: text("remark"), }); diff --git a/src/db/schema/document-branding-assets.ts b/src/db/schema/document-branding-assets.ts new file mode 100644 index 0000000..8fb68f8 --- /dev/null +++ b/src/db/schema/document-branding-assets.ts @@ -0,0 +1,18 @@ +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; diff --git a/src/domain/models/distribution-board-project-command.model.ts b/src/domain/models/distribution-board-project-command.model.ts index a41be72..2e917d0 100644 --- a/src/domain/models/distribution-board-project-command.model.ts +++ b/src/domain/models/distribution-board-project-command.model.ts @@ -1,14 +1,21 @@ import { + distributionBoardInfoFieldMaxLengths, + distributionBoardInfoFields, distributionBoardSupplyTypes, + type DistributionBoardInfoField, type DistributionBoardSupplyType, } from "../../shared/constants/distribution-board.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; export const distributionBoardUpdateCommandType = "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 interface DistributionBoardUpdateValues { +export interface DistributionBoardUpdateValues + extends Record { floorId: string | null; supplyType: DistributionBoardSupplyType | null; simultaneityFactor: number; @@ -82,16 +89,20 @@ export function assertDistributionBoardUpdateProjectCommand( for (const change of changes) { if ( !isPlainObject(change) || + typeof change.field !== "string" || (change.field !== "floorId" && change.field !== "supplyType" && - change.field !== "simultaneityFactor") || + change.field !== "simultaneityFactor" && + !isInfoField(change.field)) || seen.has(change.field) ) { throw new Error( "Distribution-board update contains an invalid or duplicate field." ); } - if (change.field === "floorId") { + if (isInfoField(change.field)) { + assertInfoFieldValue(change.field, change.value); + } else if (change.field === "floorId") { if ( change.value !== null && (typeof change.value !== "string" || !change.value.trim()) @@ -121,6 +132,29 @@ 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 { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/src/domain/models/distribution-board-subtree-snapshot.model.ts b/src/domain/models/distribution-board-subtree-snapshot.model.ts index be896a0..a0269fc 100644 --- a/src/domain/models/distribution-board-subtree-snapshot.model.ts +++ b/src/domain/models/distribution-board-subtree-snapshot.model.ts @@ -10,7 +10,9 @@ import { type DistributionBoardComponentRole, } from "../../shared/constants/distribution-board-component.js"; import { + distributionBoardInfoFields, distributionBoardSupplyTypes, + type DistributionBoardInfoField, type DistributionBoardSupplyType, } from "../../shared/constants/distribution-board.js"; import type { @@ -72,7 +74,7 @@ export interface DistributionBoardSubtreeSnapshot { floorId: string | null; supplyType: DistributionBoardSupplyType | null; simultaneityFactor: number; - }; + } & Record; circuitList: { id: string; projectId: string; @@ -169,6 +171,22 @@ export function cloneDistributionBoardSubtree( 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( value: unknown ): asserts value is DistributionBoardSubtreeSnapshot { @@ -204,6 +222,11 @@ export function assertDistributionBoardSubtreeSnapshot( if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) { 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 [ "id", "projectId", diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 7172cb7..57dafc5 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js"; +import { + distributionBoardInfoFieldMaxLengths, + distributionBoardSupplyTypes, + type DistributionBoardInfoField, +} from "../../shared/constants/distribution-board.js"; import { circuitGroupCategories, } from "../../shared/constants/circuit-group.js"; @@ -27,7 +31,8 @@ import { type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; -export const projectStateSnapshotSchemaVersion = 5 as const; +export const projectStateSnapshotSchemaVersion = 6 as const; +const boardInfoProjectStateSnapshotSchemaVersion = 5 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const; const externalModelProjectStateSnapshotSchemaVersion = 3 as const; const legacyProjectStateSnapshotSchemaVersion = 2 as const; @@ -62,7 +67,7 @@ const projectSchema = previousProjectSchema .extend({ isPublicBuilding: z.boolean() }) .strict(); -const distributionBoardSchema = z +const legacyDistributionBoardSchema = z .object({ id: idSchema, projectId: idSchema, @@ -73,6 +78,35 @@ const distributionBoardSchema = z }) .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> +) { + return boards.map((board) => ({ + ...board, + boardNumber: null, + location: null, + fedFrom: null, + remark: null, + })); +} + const circuitListSchema = z .object({ id: idSchema, @@ -457,11 +491,22 @@ export const projectStateSnapshotSchema = z }) .strict(); +const boardInfoProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(legacyDistributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModel: externalModelStateSchema, + ...projectStateSnapshotContents, + }) + .strict(); + const previousProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), project: projectSchema, - distributionBoards: z.array(distributionBoardSchema), + distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, ...projectStateSnapshotContents, @@ -472,7 +517,7 @@ const externalModelProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion), project: projectSchema, - distributionBoards: z.array(distributionBoardSchema), + distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), ...projectStateSnapshotContents, }) @@ -482,7 +527,7 @@ const legacyProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), project: projectSchema, - distributionBoards: z.array(distributionBoardSchema), + distributionBoards: z.array(legacyDistributionBoardSchema), ...projectStateSnapshotContents, }) .strict(); @@ -491,7 +536,7 @@ const baselineProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion), project: previousProjectSchema, - distributionBoards: z.array(distributionBoardSchema), + distributionBoards: z.array(legacyDistributionBoardSchema), ...projectStateSnapshotContents, }) .strict(); @@ -500,6 +545,20 @@ export type ProjectStateSnapshot = z.infer< 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( value: unknown ): ProjectStateSnapshot { @@ -521,11 +580,26 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { return value; } const schemaVersion = (value as { schemaVersion: number }).schemaVersion; + // Every supported version before 6 lacks the distribution-board info + // fields; they are upgraded as empty (null) values. + if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) { + const previous = boardInfoProjectStateSnapshotSchema.parse(value); + return { + ...previous, + schemaVersion: projectStateSnapshotSchemaVersion, + distributionBoards: withEmptyDistributionBoardInfo( + previous.distributionBoards + ), + }; + } if (schemaVersion === previousProjectStateSnapshotSchemaVersion) { const previous = previousProjectStateSnapshotSchema.parse(value); return { ...previous, schemaVersion: projectStateSnapshotSchemaVersion, + distributionBoards: withEmptyDistributionBoardInfo( + previous.distributionBoards + ), }; } if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) { @@ -533,6 +607,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { return { ...previous, schemaVersion: projectStateSnapshotSchemaVersion, + distributionBoards: withEmptyDistributionBoardInfo( + previous.distributionBoards + ), externalModel: emptyExternalModelState(), }; } @@ -541,6 +618,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { return { ...legacy, schemaVersion: projectStateSnapshotSchemaVersion, + distributionBoards: withEmptyDistributionBoardInfo( + legacy.distributionBoards + ), externalCsvConfiguration: null, externalModel: emptyExternalModelState(), }; @@ -551,6 +631,9 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { ...baseline, schemaVersion: projectStateSnapshotSchemaVersion, project: { ...baseline.project, isPublicBuilding: false }, + distributionBoards: withEmptyDistributionBoardInfo( + baseline.distributionBoards + ), externalCsvConfiguration: null, externalModel: emptyExternalModelState(), }; diff --git a/src/frontend/components/distribution-board-info-card.tsx b/src/frontend/components/distribution-board-info-card.tsx new file mode 100644 index 0000000..f2523f4 --- /dev/null +++ b/src/frontend/components/distribution-board-info-card.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { + distributionBoardInfoFieldLabels, + distributionBoardInfoFieldMaxLengths, + distributionBoardInfoFields, + type DistributionBoardInfoField, +} from "../../shared/constants/distribution-board"; +import type { DistributionBoardInfoDto } from "../types"; + +export type DistributionBoardInfoDraft = Record; + +const placeholders: Record = { + 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 ( +
+
+ Infokarte +
+
+ {distributionBoardInfoFields.map((field) => ( +
+ + {field === "remark" ? ( +