From cd89c4a4996be67191190520e1e52e4ff244481d Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 12:52:34 +0200 Subject: [PATCH 1/2] Add buildings above floors and a building structure page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects with several buildings could not be structured: floors and rooms hung directly off the project. Floors can now belong to a building (migration 0009: table buildings, nullable floors.building_id). Rooms and distribution boards keep their floor; their building follows from it. Existing floors stay without a building. Buildings use the location-structure command store with project-building.insert/.update/.delete, inverses and persistent Undo/Redo; deleting a building with assigned floors is rejected. Floor commands carry buildingId since command version 2. Version 1 floor commands from existing history predate buildings and are upgraded with buildingId = null before they are executed and persisted, so forward and inverse keep one version. Project snapshot schema 7 stores buildings and floor.buildingId; versions 1-6 are upgraded without buildings. Restore and portable transfer include buildings, and the transfer remaps building ids. Buildings, floors and rooms move from the project overview to their own page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume) that filter each other, and a sidebar entry. Floor selects in the board and room dialogs are grouped by building. Unchanged dialogs close without a command. Details in docs/building-structure.md; see issue #6. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 15 +- docs/README.md | 2 + docs/building-structure.md | 79 + docs/circuit-list-editor-api.md | 22 +- docs/current-architecture.md | 10 +- ...history-and-external-model-architecture.md | 4 + scripts/db-verify-circuit-schema.js | 14 + src/app/globals.css | 141 + src/app/projects/[projectId]/page.tsx | 305 +- .../projects/[projectId]/struktur/page.tsx | 792 +++++ src/db/migrations/0009_buildings.sql | 9 + src/db/migrations/meta/0009_snapshot.json | 2626 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/repositories/building.repository.ts | 27 + ...on-structure-project-command.repository.ts | 171 +- ...roject-state-restore-command.repository.ts | 7 + .../project-state-snapshot.persistence.ts | 8 + .../project-transfer.repository.ts | 2 + src/db/schema/buildings.ts | 11 + src/db/schema/floors.ts | 6 + ...ocation-structure-project-command.model.ts | 349 ++- .../models/project-state-snapshot.model.ts | 93 +- src/domain/models/project-transfer.model.ts | 10 + .../services/project-command.service.ts | 18 + src/frontend/components/Sidebar.tsx | 74 +- .../components/floor-select-options.tsx | 49 + src/frontend/components/room-modal.tsx | 10 +- src/frontend/types.ts | 19 + src/frontend/utils/api.ts | 62 +- src/frontend/utils/building-structure.ts | 284 ++ src/frontend/utils/project-version-history.ts | 3 + .../composition/application-repositories.ts | 2 + src/server/controllers/building.controller.ts | 111 + src/server/controllers/floor.controller.ts | 12 +- src/server/routes/project.routes.ts | 10 + .../validation/project-structure.schemas.ts | 12 + tests/building-structure-frontend-api.test.ts | 100 + tests/building-structure-view.test.ts | 284 ++ tests/project-building-structure.test.ts | 305 ++ tests/project-command.service.test.ts | 40 + tests/project-snapshot.repository.test.ts | 27 + tests/project-state-snapshot.test.ts | 124 +- 42 files changed, 5883 insertions(+), 373 deletions(-) create mode 100644 docs/building-structure.md create mode 100644 src/app/projects/[projectId]/struktur/page.tsx create mode 100644 src/db/migrations/0009_buildings.sql create mode 100644 src/db/migrations/meta/0009_snapshot.json create mode 100644 src/db/repositories/building.repository.ts create mode 100644 src/db/schema/buildings.ts create mode 100644 src/frontend/components/floor-select-options.tsx create mode 100644 src/frontend/utils/building-structure.ts create mode 100644 src/server/controllers/building.controller.ts create mode 100644 tests/building-structure-frontend-api.test.ts create mode 100644 tests/building-structure-view.test.ts create mode 100644 tests/project-building-structure.test.ts diff --git a/AGENTS.md b/AGENTS.md index 51b6616..bcd2ca6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,11 +250,14 @@ 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 6 additionally stores the distribution-board -info fields `boardNumber`, `location`, `fedFrom` and `remark` and the project -cover fields `siteAddress`, `workStage`, `projectLead` and `projectEditor`; -all older supported versions are upgraded with `null` for these fields. -Stored named and automatic snapshots of every supported version (1 to 6) +Current snapshot schema version 7 additionally stores the project buildings +and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6 +are upgraded with no buildings and `buildingId = null`. Version 6 stores the +distribution-board info fields `boardNumber`, `location`, `fedFrom` and +`remark` and the project cover fields `siteAddress`, `workStage`, +`projectLead` and `projectEditor`; all older supported versions are upgraded +with `null` for these fields. +Stored named and automatic snapshots of every supported version (1 to 7) remain restorable; they are upgraded while parsing. Version 5 stores the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with `manualQuantity = quantity`; they already contain the complete confirmed @@ -270,7 +273,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/6, restore and portable +delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable project duplication preserve the configuration; duplication remaps its internal id and project link. The project page exposes the Revit CSV modal. It edits the complete diff --git a/docs/README.md b/docs/README.md index 395b1ac..cdddb67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we Drag-and-drop, Sortierung, Filterung und Undo/Redo - [Seitenansicht und PDF-Export](print-view.md) – Deckblatt, Verteilerübersicht, Verteilerpläne, Büro-Logo +- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume, + Befehle, Snapshot-Schema 7, eigene Seite - [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/building-structure.md b/docs/building-structure.md new file mode 100644 index 0000000..1c1a455 --- /dev/null +++ b/docs/building-structure.md @@ -0,0 +1,79 @@ +# Gebäudestruktur (Gebäude → Etagen → Räume) + +Projekte mit mehreren Gebäuden werden über eine eigene Ebene **Gebäude** +gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen +hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung +ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es +nicht. Abstimmung mit Julian: Issue #6. + +## Datenmodell + +| Tabelle | Feld | Bedeutung | +|---|---|---| +| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts | +| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude | + +Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und +erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration +und kein automatisch angelegtes Standardgebäude. + +## Befehle, Undo/Redo + +Alle Änderungen laufen über die bestehende Orts-Struktur +(`ProjectLocationStructureProjectCommandRepository`) mit Revision, Inverse und +persistentem Undo/Redo: + +- `project-building.insert` / `project-building.delete` mit vollständigem + Gebäude-Snapshot (Schema-Version 1) +- `project-building.update`: ändert nur den Namen +- Löschen eines Gebäudes wird abgelehnt, solange Etagen zugeordnet sind + („Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.“) +- Etagenbefehle `project-floor.insert/.update/.delete` haben seit + Schema-Version **2** das Feld `buildingId`. `project-floor.update` darf Name + und/oder Gebäude ändern, nicht aber `sortOrder`. Ein referenziertes Gebäude + muss zum Projekt gehören. +- Version-1-Etagenbefehle aus bestehender Historie bleiben ausführbar. Sie + stammen aus der Zeit vor den Gebäuden und werden vor der Ausführung mit + `buildingId = null` auf Version 2 gehoben; auch die daraus neu gespeicherten + Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie + exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im + Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen. +- Raumbefehle bleiben unverändert (Version 1). + +## Snapshots und Transfer + +Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`. +Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer +Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte +bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum +Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht +und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue +Gebäude-IDs und stellt die Etagenverweise um. + +## API + +- `GET /api/projects/:projectId/buildings` – Gebäude sortiert nach + `sortOrder`, Name +- `POST /api/projects/:projectId/buildings` – `{ "name": "Haus A", "expectedRevision": 3 }` +- `PUT /api/projects/:projectId/buildings/:buildingId` – `{ "name": "Haus B", "expectedRevision": 4 }` +- `DELETE /api/projects/:projectId/buildings/:buildingId` – `{ "expectedRevision": 5 }` +- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich + optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten + das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert. + +## Oberfläche + +Gebäude, Etagen und Räume werden auf der eigenen Seite +`/projects/:projectId/struktur` („Gebäudestruktur“) gepflegt, erreichbar über +die Seitenleiste und die Kennzahlen der Projektübersicht. Drei Spalten +Gebäude → Etagen → Räume filtern sich gegenseitig: Klick auf ein Gebäude zeigt +nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Filter +auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der +Stromkreisliste. + +## Offen + +- Revit: Das Gebäude kann später aus einem Revit-Parameter kommen und über die + bestehende CSV-Zuordnung gemappt werden (Frage an Julian in Issue #6). +- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue + Gebäude werden hinten angehängt. diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index d325944..156be62 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -257,15 +257,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. `distribution-board.delete-subtree`. Persistent Undo/Redo restores or removes the same complete subtree, including protection devices. -### Project Floors and Rooms +### Project Buildings, Floors and Rooms +- `GET /projects/:projectId/buildings` +- `POST /projects/:projectId/buildings` + - body: `{ "name": "Haus A", "expectedRevision": 12 }` + - executes `project-building.insert` with a stable building id + - response: `{ "building": { ... }, "revision": { ... }, "history": { ... } }` +- `PUT /projects/:projectId/buildings/:buildingId` + - body: `{ "name": "Haus B", "expectedRevision": 13 }` (name only) +- `DELETE /projects/:projectId/buildings/:buildingId` + - body: `{ "expectedRevision": 14 }` + - rejects buildings with assigned floors - `POST /projects/:projectId/floors` - - body: `{ "name": "EG", "expectedRevision": 13 }` - - executes `project-floor.insert` with a stable floor id + - body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }` + (`buildingId` optional, `null` = no building) + - executes `project-floor.insert` (command version 2) with a stable floor id - response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` - `PUT /projects/:projectId/floors/:floorId` - - body: `{ "name": "1. OG", "expectedRevision": 14 }` - - executes an exact persistent floor update + - body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }` + - executes an exact persistent floor update of name and/or building; a + missing `buildingId` keeps the current building - `DELETE /projects/:projectId/floors/:floorId` - body: `{ "expectedRevision": 15 }` - rejects floors with assigned rooms or distribution boards diff --git a/docs/current-architecture.md b/docs/current-architecture.md index fe55413..191c3f4 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -246,9 +246,11 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen. -Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte -Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals -statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare, +Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre +versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter +Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite +„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können +einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare, fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, @@ -422,6 +424,8 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext. +Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung +der Etagen; ältere Versionen werden ohne Gebäude hochgestuft. Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des Projekts; ältere Versionen werden mit `null` hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 4c0a8ea..a7c1300 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -275,6 +275,10 @@ Completed foundation: - `project-floor.insert` and `project-room.insert` persist complete location records with stable ids; their inverses reject changed or referenced records so Undo never clears room assignments or device-row links implicitly +- `project-building.insert/.update/.delete` persist complete building records; + deleting a building with assigned floors is rejected. Floor commands carry + `buildingId` since command version 2; version 1 floor commands from older + history are upgraded with `buildingId = null` before execution - the floor and room POST routes require the current expected revision and the project page tracks the returned history state diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 8d27b1d..947767a 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -5,6 +5,7 @@ const dbPath = path.resolve("data", "leistungsbilanz.db"); const db = new Database(dbPath, { readonly: true }); const requiredTables = [ + "buildings", "circuit_sections", "circuits", "circuit_device_rows", @@ -100,6 +101,13 @@ const projectColumns = new Set( const missingProjectColumns = requiredProjectColumns.filter( (name) => !projectColumns.has(name) ); +const requiredFloorColumns = ["building_id"]; +const floorColumns = new Set( + db.prepare("PRAGMA table_info(floors)").all().map((column) => column.name) +); +const missingFloorColumns = requiredFloorColumns.filter( + (name) => !floorColumns.has(name) +); const requiredDistributionBoardColumns = [ "floor_id", "supply_type", @@ -155,6 +163,7 @@ console.log( requiredCircuitSectionColumns.join(", ") ); console.log("Required project columns:", requiredProjectColumns.join(", ")); +console.log("Required floor columns:", requiredFloorColumns.join(", ")); console.log( "Required distribution-board columns:", requiredDistributionBoardColumns.join(", ") @@ -171,6 +180,11 @@ if (missing.length > 0) { process.exit(1); } +if (missingFloorColumns.length > 0) { + console.error("Missing floor columns:", missingFloorColumns.join(", ")); + process.exit(1); +} + if (missingProjectDeviceColumns.length > 0) { console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", ")); process.exit(1); diff --git a/src/app/globals.css b/src/app/globals.css index 22da5df..f392303 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1461,3 +1461,144 @@ a.kpi:hover { .sidebar-collapsed .sidebar-label { display: none; } + +/* ── Gebäudestruktur (Gebäude → Etagen → Räume) ─────────────────────── */ + +.structure-columns { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--space-md); + align-items: start; +} + +@media (min-width: 992px) { + .structure-columns { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.structure-column-title { + font-size: 1rem; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; +} + +.structure-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 0.4rem; + line-height: 1; +} + +.structure-back-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.45rem; + margin-top: 0.1rem; +} + +.structure-list { + max-height: 70vh; + overflow-y: auto; +} + +.structure-entry { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: 0; +} + +.structure-entry-main { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); + padding: 0.55rem 0.75rem; + border: 0; + background: transparent; + color: inherit; + text-align: left; + font: inherit; +} + +button.structure-entry-main { + cursor: pointer; +} + +button.structure-entry-main:hover { + background: var(--surface-hover); +} + +button.structure-entry-main:focus-visible { + outline: 2px solid var(--accent-blue); + outline-offset: -2px; +} + +.structure-entry.is-selected { + background: var(--surface-selected); + box-shadow: inset 3px 0 0 var(--color-signal); +} + +.structure-entry.is-selected .structure-entry-label { + font-weight: 600; +} + +.structure-entry.is-selected button.structure-entry-main:hover { + background: transparent; +} + +.structure-entry-text { + min-width: 0; + display: flex; + flex-direction: column; +} + +.structure-entry-label { + overflow-wrap: anywhere; +} + +.structure-entry-hint { + font-size: 0.78rem; + color: var(--color-ink-soft); +} + +.structure-entry-meta { + flex: 0 0 auto; + font-size: 0.78rem; + color: var(--color-ink-soft); + white-space: nowrap; +} + +.structure-entry-actions { + flex: 0 0 auto; + display: inline-flex; + gap: 4px; + padding-right: 0.75rem; +} + +.structure-room-number { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.structure-group-label { + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-accent); + background: var(--panel-bg-subtle); + padding: 0.35rem 0.75rem; +} + +.structure-empty { + color: var(--color-ink-soft); + font-size: 0.9rem; +} diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 73f9460..0719078 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -8,17 +8,15 @@ import { copyDistributionBoard, copyProjectDeviceToGlobal, createDistributionBoard, - createFloor, createProjectDevice, createRoom, - deleteFloor, deleteProjectDevice, - deleteRoom, deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, getProject, getProjectDeviceSyncPreview, + listBuildings, listCircuitLists, listDistributionBoards, listFloors, @@ -28,12 +26,11 @@ import { importProjectTransfer, synchronizeProjectDeviceRows, updateDistributionBoard, - updateFloor, updateProjectDevice, updateProjectSettings, - updateRoom, } from "../../../frontend/utils/api"; import type { + BuildingDto, CircuitListDto, CreateRoomInput, CreateProjectDeviceInput, @@ -67,7 +64,7 @@ import { import { FormModal } from "../../../frontend/components/form-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; -import { RoomModal } from "../../../frontend/components/room-modal"; +import { FloorSelectOptions } from "../../../frontend/components/floor-select-options"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -89,6 +86,7 @@ export default function ProjectDetailPage() { const [project, setProject] = useState(null); const [boards, setBoards] = useState([]); const [circuitLists, setCircuitLists] = useState([]); + const [buildings, setBuildings] = useState([]); const [floors, setFloors] = useState([]); const [rooms, setRooms] = useState([]); const [projectDevices, setProjectDevices] = useState([]); @@ -116,14 +114,9 @@ export default function ProjectDetailPage() { remark: null, }) ); - const [floorName, setFloorName] = useState(""); - const [editingFloor, setEditingFloor] = useState(null); - const [editingRoom, setEditingRoom] = useState(null); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); - const [structureModal, setStructureModal] = useState< - "board" | "floor" | "room" | null - >(null); + const [structureModal, setStructureModal] = useState<"board" | null>(null); const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = useState(false); const [editingProjectDevice, setEditingProjectDevice] = @@ -147,6 +140,7 @@ export default function ProjectDetailPage() { getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), + listBuildings(projectId), listFloors(projectId), listRooms(projectId), listProjectDevices(projectId), @@ -156,6 +150,7 @@ export default function ProjectDetailPage() { currentProject, distributionBoards, loadedCircuitLists, + loadedBuildings, loadedFloors, loadedRooms, loadedProjectDevices, @@ -164,6 +159,7 @@ export default function ProjectDetailPage() { setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); + setBuildings(loadedBuildings); setFloors(loadedFloors); setRooms(loadedRooms); setProjectDevices(loadedProjectDevices); @@ -176,6 +172,7 @@ export default function ProjectDetailPage() { }, [projectId]); const boardCount = useMemo(() => boards.length, [boards.length]); + const buildingCount = useMemo(() => buildings.length, [buildings.length]); const floorCount = useMemo(() => floors.length, [floors.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); @@ -260,74 +257,6 @@ export default function ProjectDetailPage() { } } - async function handleSaveFloor(event: FormEvent) { - event.preventDefault(); - if (!projectId || !project || !floorName.trim()) { - return; - } - setIsSaving(true); - setError(null); - try { - const result = editingFloor - ? await updateFloor( - projectId, - editingFloor.id, - { name: floorName.trim() }, - project.currentRevision - ) - : await createFloor( - projectId, - { name: floorName.trim() }, - project.currentRevision - ); - setFloors((current) => - editingFloor - ? current.map((floor) => - floor.id === result.floor.id ? result.floor : floor - ) - : [...current, result.floor] - ); - applyProjectRevision(result.history.currentRevision); - setFloorName(""); - setEditingFloor(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - - async function handleSaveRoom(input: CreateRoomInput) { - if (!projectId || !project) return; - setIsSaving(true); - setError(null); - try { - const result = editingRoom - ? await updateRoom( - projectId, - editingRoom.id, - input, - project.currentRevision - ) - : await createRoom(projectId, input, project.currentRevision); - setRooms((current) => - editingRoom - ? current.map((room) => - room.id === result.room.id ? result.room : room - ) - : [...current, result.room] - ); - applyProjectRevision(result.history.currentRevision); - setEditingRoom(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden."); - } finally { - setIsSaving(false); - } - } - async function handleSaveProjectSettings(input: ProjectSettingsInput) { if (!projectId || !project) { return; @@ -515,65 +444,6 @@ export default function ProjectDetailPage() { return result; } - function openCreateFloor() { - setEditingFloor(null); - setFloorName(""); - setStructureModal("floor"); - } - - function openEditFloor(floor: FloorDto) { - setEditingFloor(floor); - setFloorName(floor.name); - setStructureModal("floor"); - } - - function openCreateRoom() { - setEditingRoom(null); - setStructureModal("room"); - } - - function openEditRoom(room: RoomDto) { - setEditingRoom(room); - setStructureModal("room"); - } - - async function handleDeleteFloor() { - if (!projectId || !project || !editingFloor) return; - if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return; - setIsSaving(true); - setError(null); - try { - const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision); - setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id)); - applyProjectRevision(result.history.currentRevision); - setEditingFloor(null); - setFloorName(""); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden."); - } finally { - setIsSaving(false); - } - } - - async function handleDeleteRoom() { - if (!projectId || !project || !editingRoom) return; - if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return; - setIsSaving(true); - setError(null); - try { - const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision); - setRooms((current) => current.filter((room) => room.id !== editingRoom.id)); - applyProjectRevision(result.history.currentRevision); - setEditingRoom(null); - setStructureModal(null); - } catch (err) { - setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden."); - } finally { - setIsSaving(false); - } - } - async function handleCopyBoard() { if ( !projectId || @@ -902,14 +772,18 @@ export default function ProjectDetailPage() {
Verteilungen
{boardCount}
- + +
Gebäude
+
{buildingCount}
+ +
Etagen
{floorCount}
-
- + +
Räume
{roomCount}
-
+
Projektgeräte
{projectDeviceCount}
@@ -1025,88 +899,6 @@ export default function ProjectDetailPage() { - -
-
-
-
- Etagen - {floorCount} -
- -
-
    - {floors.map((floor) => ( -
  • - {floor.name} - -
  • - ))} - {!floors.length ? ( -
  • Noch keine Etagen vorhanden.
  • - ) : null} -
-
-
- -
-
-
-
- Räume - {roomCount} -
- -
-
- - - - - - - - - - - {rooms.map((room) => ( - - - - - - - ))} - {!rooms.length ? ( - - - - ) : null} - -
RaumnummerRaumnameEtageAktionen
{room.roomNumber}{room.roomName}{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"} - -
- Noch keine Räume vorhanden. -
-
-
-
@@ -1366,11 +1158,7 @@ export default function ProjectDetailPage() { value={boardFloorId} > - {floors.map((floor) => ( - - ))} +
@@ -1436,11 +1224,7 @@ export default function ProjectDetailPage() { value={editingBoardFloorId} > - {floors.map((floor) => ( - - ))} +
@@ -1572,57 +1356,6 @@ export default function ProjectDetailPage() { ) : null} - {structureModal === "floor" ? ( - { - setStructureModal(null); - setEditingFloor(null); - }} - onSubmit={handleSaveFloor} - submitDisabled={!floorName.trim()} - submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"} - title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"} - > - - setFloorName(event.target.value)} - placeholder="z. B. EG" - required - value={floorName} - /> - {editingFloor ? ( -
-

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

- -
- ) : null} -
- ) : null} - - {structureModal === "room" ? ( - { - setStructureModal(null); - setEditingRoom(null); - }} - onDelete={editingRoom ? () => void handleDeleteRoom() : undefined} - onSave={handleSaveRoom} - /> - ) : null} - {isProjectDeviceModalOpen ? ( void; + title: string; +} + +function StructureColumn({ + addLabel, + children, + count, + headingId, + isAddDisabled, + onAdd, + title, +}: StructureColumnProps) { + return ( +
+
+

+ {title} + {count} +

+ +
+
    {children}
+
+ ); +} + +interface StructureEntryProps { + actions?: ReactNode; + hint?: string; + isSelected?: boolean; + label: ReactNode; + meta?: string; + onSelect?: () => void; +} + +function StructureEntry({ + actions, + hint, + isSelected = false, + label, + meta, + onSelect, +}: StructureEntryProps) { + const content = ( + <> + + {label} + {hint ? {hint} : null} + + {meta ? {meta} : null} + + ); + return ( +
  • + {onSelect ? ( + + ) : ( +
    {content}
    + )} + {actions ?
    {actions}
    : null} +
  • + ); +} + +interface EntryActionsProps { + deleteLabel: string; + editLabel: string; + isDisabled: boolean; + onDelete: () => void; + onEdit: () => void; +} + +function EntryActions({ + deleteLabel, + editLabel, + isDisabled, + onDelete, + onEdit, +}: EntryActionsProps) { + return ( + <> + + + + ); +} + +export default function BuildingStructurePage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const [project, setProject] = useState(null); + const [buildings, setBuildings] = useState([]); + const [floors, setFloors] = useState([]); + const [rooms, setRooms] = useState([]); + const [buildingSelection, setBuildingSelection] = + useState(ALL_SELECTION); + const [floorSelection, setFloorSelection] = + useState(ALL_SELECTION); + const [dialog, setDialog] = useState(null); + const [dialogName, setDialogName] = useState(""); + const [dialogBuildingId, setDialogBuildingId] = useState(""); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const reloadStructure = useCallback(async () => { + const [currentProject, loadedBuildings, loadedFloors, loadedRooms] = + await Promise.all([ + getProject(projectId), + listBuildings(projectId), + listFloors(projectId), + listRooms(projectId), + ]); + setProject(currentProject); + setBuildings(loadedBuildings); + setFloors(loadedFloors); + setRooms(loadedRooms); + }, [projectId]); + + useEffect(() => { + if (!projectId) { + return; + } + setIsLoading(true); + reloadStructure() + .then(() => setError(null)) + .catch((err: unknown) => + setError( + err instanceof Error + ? err.message + : "Gebäudestruktur konnte nicht geladen werden." + ) + ) + .finally(() => setIsLoading(false)); + }, [projectId, reloadStructure]); + + const effectiveBuildingSelection = normalizeBuildingSelection( + buildingSelection, + floors, + buildings + ); + const effectiveFloorSelection = normalizeFloorSelection( + floorSelection, + rooms, + floors, + buildings, + effectiveBuildingSelection + ); + + const visibleFloors = useMemo( + () => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection), + [buildings, effectiveBuildingSelection, floors] + ); + const visibleFloorGroups = useMemo( + () => + effectiveBuildingSelection === ALL_SELECTION && buildings.length + ? groupFloorsByBuilding(visibleFloors, buildings) + : [{ buildingId: null, label: "", floors: visibleFloors }], + [buildings, effectiveBuildingSelection, visibleFloors] + ); + const visibleRooms = useMemo( + () => + filterRooms( + rooms, + floors, + buildings, + effectiveBuildingSelection, + effectiveFloorSelection + ).sort(compareRooms), + [buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms] + ); + const roomsInBuildingSelection = useMemo( + () => + filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION) + .length, + [buildings, effectiveBuildingSelection, floors, rooms] + ); + const floorCountByBuilding = useMemo( + () => countFloorsByBuilding(floors, buildings), + [buildings, floors] + ); + const roomCountByFloor = useMemo( + () => countRoomsByFloor(rooms, floors), + [floors, rooms] + ); + const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings); + const showUnassignedFloor = showsRoomsWithoutFloorEntry( + rooms, + floors, + effectiveBuildingSelection + ); + const isBusy = isSaving || !project; + + function selectBuilding(selection: StructureSelection) { + setBuildingSelection(selection); + setFloorSelection(ALL_SELECTION); + } + + async function runCommand( + execute: (expectedRevision: number) => Promise, + fallbackMessage: string + ): Promise { + if (!project) { + return false; + } + setIsSaving(true); + setError(null); + try { + const result = await execute(project.currentRevision); + setProject((current) => + current + ? { ...current, currentRevision: result.history.currentRevision } + : current + ); + await reloadStructure(); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : fallbackMessage); + // Refresh the revision and lists so a stale state does not block the + // next attempt; the command error stays visible. + await reloadStructure().catch(() => undefined); + return false; + } finally { + setIsSaving(false); + } + } + + function openBuildingDialog(building: BuildingDto | null) { + setDialogName(building?.name ?? ""); + setDialog({ kind: "building", building }); + } + + function openFloorDialog(floor: FloorDto | null) { + setDialogName(floor?.name ?? ""); + setDialogBuildingId( + floor + ? floor.buildingId ?? "" + : defaultBuildingIdForNewFloor(effectiveBuildingSelection) + ); + setDialog({ kind: "floor", floor }); + } + + function openRoomDialog(room: RoomDto | null) { + setDialog({ + kind: "room", + room, + defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection), + }); + } + + async function handleSaveBuilding(event: FormEvent) { + event.preventDefault(); + if (dialog?.kind !== "building" || !dialogName.trim()) { + return; + } + const editingBuilding = dialog.building; + const name = dialogName.trim(); + // An unchanged dialog is not a command; the backend rejects no-op updates. + if (editingBuilding && editingBuilding.name === name) { + setDialog(null); + return; + } + const saved = await runCommand( + (expectedRevision) => + editingBuilding + ? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision) + : createBuilding(projectId, { name }, expectedRevision), + "Gebäude konnte nicht gespeichert werden." + ); + if (saved) { + setDialog(null); + } + } + + async function handleSaveFloor(event: FormEvent) { + event.preventDefault(); + if (dialog?.kind !== "floor" || !dialogName.trim()) { + return; + } + const editingFloor = dialog.floor; + const input = { + name: dialogName.trim(), + buildingId: dialogBuildingId || null, + }; + if ( + editingFloor && + editingFloor.name === input.name && + editingFloor.buildingId === input.buildingId + ) { + setDialog(null); + return; + } + const saved = await runCommand( + (expectedRevision) => + editingFloor + ? updateFloor(projectId, editingFloor.id, input, expectedRevision) + : createFloor(projectId, input, expectedRevision), + "Etage konnte nicht gespeichert werden." + ); + if (saved) { + setDialog(null); + } + } + + async function handleSaveRoom(input: CreateRoomInput) { + if (dialog?.kind !== "room") { + return; + } + const editingRoom = dialog.room; + if ( + editingRoom && + editingRoom.roomNumber === input.roomNumber.trim() && + editingRoom.roomName === input.roomName.trim() && + editingRoom.floorId === (input.floorId || null) + ) { + setDialog(null); + return; + } + const saved = await runCommand( + (expectedRevision) => + editingRoom + ? updateRoom(projectId, editingRoom.id, input, expectedRevision) + : createRoom(projectId, input, expectedRevision), + "Raum konnte nicht gespeichert werden." + ); + if (saved) { + setDialog(null); + } + } + + async function handleDeleteBuilding(building: BuildingDto) { + if ((floorCountByBuilding.get(building.id) ?? 0) > 0) { + setError(BUILDING_HAS_FLOORS_MESSAGE); + return; + } + if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) { + return; + } + await runCommand( + (expectedRevision) => + deleteBuilding(projectId, building.id, expectedRevision), + "Gebäude konnte nicht gelöscht werden." + ); + } + + async function handleDeleteFloor(floor: FloorDto) { + if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) { + return; + } + await runCommand( + (expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision), + "Etage konnte nicht gelöscht werden." + ); + } + + async function handleDeleteRoom(room: RoomDto) { + if ( + !window.confirm( + `Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?` + ) + ) { + return; + } + await runCommand( + (expectedRevision) => deleteRoom(projectId, room.id, expectedRevision), + "Raum konnte nicht gelöscht werden." + ); + } + + return ( +
    +
    +
    + +
    +
    + + {error ? ( +
    + {error} +
    + ) : null} + {isLoading ? ( +

    + Gebäudestruktur wird geladen … +

    + ) : null} + +
    + openBuildingDialog(null)} + title="Gebäude" + > + selectBuilding(ALL_SELECTION)} + /> + {buildings.map((building) => ( + void handleDeleteBuilding(building)} + onEdit={() => openBuildingDialog(building)} + /> + } + isSelected={effectiveBuildingSelection === building.id} + key={building.id} + label={building.name} + meta={formatCount( + floorCountByBuilding.get(building.id) ?? 0, + "Etage", + "Etagen" + )} + onSelect={() => selectBuilding(building.id)} + /> + ))} + {showUnassignedBuilding ? ( + {UNASSIGNED_BUILDING_LABEL}} + meta={formatCount( + floorCountByBuilding.get(null) ?? 0, + "Etage", + "Etagen" + )} + onSelect={() => selectBuilding(UNASSIGNED_SELECTION)} + /> + ) : null} + {!buildings.length && !isLoading ? ( +
  • + Noch keine Gebäude vorhanden. +
  • + ) : null} +
    + + openFloorDialog(null)} + title="Etagen" + > + setFloorSelection(ALL_SELECTION)} + /> + {visibleFloorGroups.map((group) => ( + + {group.floors.map((floor) => ( + void handleDeleteFloor(floor)} + onEdit={() => openFloorDialog(floor)} + /> + } + isSelected={effectiveFloorSelection === floor.id} + key={floor.id} + label={floor.name} + meta={formatCount( + roomCountByFloor.get(floor.id) ?? 0, + "Raum", + "Räume" + )} + onSelect={() => setFloorSelection(floor.id)} + /> + ))} + + ))} + {showUnassignedFloor ? ( + {UNASSIGNED_FLOOR_LABEL}} + meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")} + onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)} + /> + ) : null} + {!visibleFloors.length && !isLoading ? ( +
  • + {floors.length + ? "Keine Etagen in dieser Auswahl." + : "Noch keine Etagen vorhanden."} +
  • + ) : null} +
    + + openRoomDialog(null)} + title="Räume" + > + {visibleRooms.map((room) => ( + void handleDeleteRoom(room)} + onEdit={() => openRoomDialog(room)} + /> + } + hint={ + effectiveFloorSelection === ALL_SELECTION + ? describeRoomLocation(room, floors, buildings) + : undefined + } + key={room.id} + label={ + <> + {room.roomNumber}{" "} + {room.roomName} + + } + /> + ))} + {!visibleRooms.length && !isLoading ? ( +
  • + {rooms.length + ? "Keine Räume in dieser Auswahl." + : "Noch keine Räume vorhanden."} +
  • + ) : null} +
    +
    + + {dialog?.kind === "building" ? ( + setDialog(null)} + onSubmit={handleSaveBuilding} + submitDisabled={!dialogName.trim()} + submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"} + title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"} + > + + setDialogName(event.target.value)} + placeholder="z. B. Haus A" + required + value={dialogName} + /> + + ) : null} + + {dialog?.kind === "floor" ? ( + setDialog(null)} + onSubmit={handleSaveFloor} + submitDisabled={!dialogName.trim()} + submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"} + title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"} + > +
    +
    + + setDialogName(event.target.value)} + placeholder="z. B. EG" + required + value={dialogName} + /> +
    +
    + + +
    +
    +
    + ) : null} + + {dialog?.kind === "room" ? ( + setDialog(null)} + onSave={handleSaveRoom} + /> + ) : null} +
    + ); +} + +function FloorGroupEntries({ + children, + label, +}: { + children: ReactNode; + label: string; +}) { + return ( + <> + {label ? ( +
  • + {label} +
  • + ) : null} + {children} + + ); +} diff --git a/src/db/migrations/0009_buildings.sql b/src/db/migrations/0009_buildings.sql new file mode 100644 index 0000000..6f65174 --- /dev/null +++ b/src/db/migrations/0009_buildings.sql @@ -0,0 +1,9 @@ +CREATE TABLE `buildings` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null; \ No newline at end of file diff --git a/src/db/migrations/meta/0009_snapshot.json b/src/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..1b7d566 --- /dev/null +++ b/src/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,2626 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2cd9adef-2f08-4635-b3d9-ecf71f624c7f", + "prevId": "fb609719-ea17-4833-a27b-f75da0d3e860", + "tables": { + "buildings": { + "name": "buildings", + "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": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "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 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 8322eb3..485b8f4 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1790411560953, "tag": "0008_project_cover_and_office_profile", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1790419185882, + "tag": "0009_buildings", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/building.repository.ts b/src/db/repositories/building.repository.ts new file mode 100644 index 0000000..011739f --- /dev/null +++ b/src/db/repositories/building.repository.ts @@ -0,0 +1,27 @@ +import { and, asc, eq } from "drizzle-orm"; +import type { AppDatabase } from "../database-context.js"; +import { buildings } from "../schema/buildings.js"; + +export class BuildingRepository { + constructor(private readonly database: AppDatabase) {} + + async listByProject(projectId: string) { + return this.database + .select() + .from(buildings) + .where(eq(buildings.projectId, projectId)) + .orderBy(asc(buildings.sortOrder), asc(buildings.name)); + } + + async findById(projectId: string, buildingId: string) { + return ( + this.database + .select() + .from(buildings) + .where( + and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId)) + ) + .get() ?? null + ); + } +} diff --git a/src/db/repositories/project-location-structure-project-command.repository.ts b/src/db/repositories/project-location-structure-project-command.repository.ts index 7cbadde..9ec3f48 100644 --- a/src/db/repositories/project-location-structure-project-command.repository.ts +++ b/src/db/repositories/project-location-structure-project-command.repository.ts @@ -1,23 +1,34 @@ import { and, eq } from "drizzle-orm"; import { + assertProjectBuildingDeleteProjectCommand, + assertProjectBuildingInsertProjectCommand, + assertProjectBuildingUpdateProjectCommand, assertProjectFloorDeleteProjectCommand, assertProjectFloorInsertProjectCommand, assertProjectFloorUpdateProjectCommand, assertProjectRoomDeleteProjectCommand, assertProjectRoomInsertProjectCommand, assertProjectRoomUpdateProjectCommand, + createProjectBuildingDeleteProjectCommand, + createProjectBuildingInsertProjectCommand, + createProjectBuildingUpdateProjectCommand, createProjectFloorDeleteProjectCommand, createProjectFloorInsertProjectCommand, createProjectFloorUpdateProjectCommand, createProjectRoomDeleteProjectCommand, createProjectRoomInsertProjectCommand, createProjectRoomUpdateProjectCommand, + projectBuildingDeleteCommandType, + projectBuildingInsertCommandType, + projectBuildingUpdateCommandType, projectFloorDeleteCommandType, projectFloorInsertCommandType, projectFloorUpdateCommandType, projectRoomDeleteCommandType, projectRoomInsertCommandType, projectRoomUpdateCommandType, + upgradeProjectFloorCommand, + type ProjectBuildingSnapshot, type ProjectFloorSnapshot, type ProjectLocationStructureProjectCommand, type ProjectRoomSnapshot, @@ -27,6 +38,7 @@ import type { ProjectLocationStructureProjectCommandStore, } from "../../domain/ports/project-location-structure-project-command.store.js"; import type { AppDatabase } from "../database-context.js"; +import { buildings } from "../schema/buildings.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { floors } from "../schema/floors.js"; import { distributionBoards } from "../schema/distribution-boards.js"; @@ -40,11 +52,14 @@ export class ProjectLocationStructureProjectCommandRepository constructor(private readonly database: AppDatabase) {} execute(input: ExecuteProjectLocationStructureCommandInput) { + // Forward and inverse are persisted with one schema version, so a + // version 1 floor command from older history is upgraded first. + const command = toCurrentCommandVersion(input.command); return executeProjectCommandTransaction( this.database, - input, + { ...input, command }, (tx) => - this.applyCommand(tx, input.projectId, input.command) + this.applyCommand(tx, input.projectId, command) ); } @@ -54,23 +69,43 @@ export class ProjectLocationStructureProjectCommandRepository command: ProjectLocationStructureProjectCommand ): ProjectLocationStructureProjectCommand { switch (command.type) { - case projectFloorInsertCommandType: + case projectBuildingInsertCommandType: + assertProjectBuildingInsertProjectCommand(command); + return this.insertBuilding(database, projectId, command.payload.building); + case projectBuildingDeleteCommandType: + assertProjectBuildingDeleteProjectCommand(command); + return this.deleteBuilding(database, projectId, command.payload.building); + case projectBuildingUpdateCommandType: + assertProjectBuildingUpdateProjectCommand(command); + return this.updateBuilding( + database, + projectId, + command.payload.expected, + command.payload.target + ); + case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(command); + const current = upgradeProjectFloorCommand(command); return this.insertFloor( database, projectId, - command.payload.floor + current.payload.floor ); - case projectFloorDeleteCommandType: + } + case projectFloorDeleteCommandType: { assertProjectFloorDeleteProjectCommand(command); + const current = upgradeProjectFloorCommand(command); return this.deleteFloor( database, projectId, - command.payload.floor + current.payload.floor ); - case projectFloorUpdateCommandType: + } + case projectFloorUpdateCommandType: { assertProjectFloorUpdateProjectCommand(command); - return this.updateFloor(database, projectId, command.payload.expected, command.payload.target); + const current = upgradeProjectFloorCommand(command); + return this.updateFloor(database, projectId, current.payload.expected, current.payload.target); + } case projectRoomInsertCommandType: assertProjectRoomInsertProjectCommand(command); return this.insertRoom( @@ -100,6 +135,7 @@ export class ProjectLocationStructureProjectCommandRepository if (floor.projectId !== projectId) { throw new Error("Project-floor snapshot does not belong to project."); } + this.assertBuildingInProject(database, projectId, floor.buildingId); const existing = database .select({ id: floors.id }) .from(floors) @@ -167,6 +203,104 @@ export class ProjectLocationStructureProjectCommandRepository return createProjectFloorInsertProjectCommand(expected); } + private insertBuilding( + database: AppDatabase, + projectId: string, + building: ProjectBuildingSnapshot + ) { + this.assertProjectExists(database, projectId); + if (building.projectId !== projectId) { + throw new Error("Project-building snapshot does not belong to project."); + } + const existing = database + .select({ id: buildings.id }) + .from(buildings) + .where(eq(buildings.id, building.id)) + .get(); + if (existing) { + throw new Error("Project-building id already exists."); + } + database.insert(buildings).values(building).run(); + return createProjectBuildingDeleteProjectCommand(building); + } + + private deleteBuilding( + database: AppDatabase, + projectId: string, + expected: ProjectBuildingSnapshot + ) { + const persisted = database + .select() + .from(buildings) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .get(); + if (!persisted || !sameRecord(expected, persisted)) { + throw new Error("Project building changed before deletion."); + } + const referencedFloor = database + .select({ id: floors.id }) + .from(floors) + .where(eq(floors.buildingId, expected.id)) + .limit(1) + .get(); + if (referencedFloor) { + throw new Error( + "Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind." + ); + } + const deleted = database + .delete(buildings) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .run(); + if (deleted.changes !== 1) { + throw new Error("Project building could not be deleted."); + } + return createProjectBuildingInsertProjectCommand(expected); + } + + private updateBuilding( + database: AppDatabase, + projectId: string, + expected: ProjectBuildingSnapshot, + target: ProjectBuildingSnapshot + ) { + const persisted = database + .select() + .from(buildings) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .get(); + if (!persisted || !sameRecord(expected, persisted)) { + throw new Error("Project building changed before update."); + } + const updated = database + .update(buildings) + .set({ name: target.name }) + .where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId))) + .run(); + if (updated.changes !== 1) { + throw new Error("Das Gebäude konnte nicht aktualisiert werden."); + } + return createProjectBuildingUpdateProjectCommand(target, expected); + } + + private assertBuildingInProject( + database: AppDatabase, + projectId: string, + buildingId: string | null + ) { + if (buildingId === null) { + return; + } + const building = database + .select({ id: buildings.id }) + .from(buildings) + .where(and(eq(buildings.id, buildingId), eq(buildings.projectId, projectId))) + .get(); + if (!building) { + throw new Error("Project floor references a foreign building."); + } + } + private insertRoom( database: AppDatabase, projectId: string, @@ -217,9 +351,10 @@ export class ProjectLocationStructureProjectCommandRepository if (!persisted || !sameRecord(expected, persisted)) { throw new Error("Project floor changed before update."); } + this.assertBuildingInProject(database, projectId, target.buildingId); const updated = database .update(floors) - .set({ name: target.name }) + .set({ name: target.name, buildingId: target.buildingId }) .where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId))) .run(); if (updated.changes !== 1) { @@ -326,6 +461,24 @@ export class ProjectLocationStructureProjectCommandRepository } } +function toCurrentCommandVersion( + command: ProjectLocationStructureProjectCommand +): ProjectLocationStructureProjectCommand { + switch (command.type) { + case projectFloorInsertCommandType: + assertProjectFloorInsertProjectCommand(command); + return upgradeProjectFloorCommand(command); + case projectFloorDeleteCommandType: + assertProjectFloorDeleteProjectCommand(command); + return upgradeProjectFloorCommand(command); + case projectFloorUpdateCommandType: + assertProjectFloorUpdateProjectCommand(command); + return upgradeProjectFloorCommand(command); + default: + return command; + } +} + function sameRecord( expected: object, actual: object diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index cf6f9bc..496f2cb 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -21,6 +21,7 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { buildings } from "../schema/buildings.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projectChangeSets } from "../schema/project-change-sets.js"; @@ -164,6 +165,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) { })) ), projectDevices: byId(state.projectDevices), + buildings: byId(state.buildings), floors: byId(state.floors), rooms: byId(state.rooms), externalCsvConfiguration: state.externalCsvConfiguration, @@ -219,6 +221,10 @@ function replaceProjectState( .delete(floors) .where(eq(floors.projectId, state.project.id)) .run(); + database + .delete(buildings) + .where(eq(buildings.projectId, state.project.id)) + .run(); database .update(projects) @@ -241,6 +247,7 @@ function replaceProjectState( .where(eq(projects.id, state.project.id)) .run(); + insertMany(database, buildings, state.buildings); insertMany(database, floors, state.floors); if (state.externalCsvConfiguration !== null) { database diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts index 84131e7..a11cd58 100644 --- a/src/db/repositories/project-state-snapshot.persistence.ts +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -15,6 +15,7 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { buildings } from "../schema/buildings.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; @@ -219,6 +220,12 @@ export function readProjectStateSnapshot( .where(eq(projectDevices.projectId, projectId)) .orderBy(asc(projectDevices.displayName), asc(projectDevices.id)) .all(); + const buildingRows = database + .select() + .from(buildings) + .where(eq(buildings.projectId, projectId)) + .orderBy(asc(buildings.sortOrder), asc(buildings.id)) + .all(); const floorRows = database .select() .from(floors) @@ -273,6 +280,7 @@ export function readProjectStateSnapshot( deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [], })), projectDevices: projectDeviceRows, + buildings: buildingRows, floors: floorRows, rooms: roomRows, }); diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index 07a5067..11f23f7 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -16,6 +16,7 @@ import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; +import { buildings } from "../schema/buildings.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projects } from "../schema/projects.js"; @@ -127,6 +128,7 @@ function insertProjectState( .values(state.externalCsvConfiguration) .run(); } + insertMany(database, buildings, state.buildings); insertMany(database, floors, state.floors); insertMany(database, rooms, state.rooms); insertMany(database, projectDevices, state.projectDevices); diff --git a/src/db/schema/buildings.ts b/src/db/schema/buildings.ts new file mode 100644 index 0000000..7d2fb78 --- /dev/null +++ b/src/db/schema/buildings.ts @@ -0,0 +1,11 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +export const buildings = sqliteTable("buildings", { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + sortOrder: integer("sort_order").notNull().default(0), +}); diff --git a/src/db/schema/floors.ts b/src/db/schema/floors.ts index 5c68753..bd50534 100644 --- a/src/db/schema/floors.ts +++ b/src/db/schema/floors.ts @@ -1,4 +1,5 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { buildings } from "./buildings.js"; import { projects } from "./projects.js"; export const floors = sqliteTable("floors", { @@ -8,4 +9,9 @@ export const floors = sqliteTable("floors", { .references(() => projects.id, { onDelete: "cascade" }), name: text("name").notNull(), sortOrder: integer("sort_order").notNull().default(0), + // Optional parent building. Floors created before buildings existed stay + // without one and are shown as "Ohne Gebäude". + buildingId: text("building_id").references(() => buildings.id, { + onDelete: "set null", + }), }); diff --git a/src/domain/models/project-location-structure-project-command.model.ts b/src/domain/models/project-location-structure-project-command.model.ts index 616add9..21dfa89 100644 --- a/src/domain/models/project-location-structure-project-command.model.ts +++ b/src/domain/models/project-location-structure-project-command.model.ts @@ -1,21 +1,42 @@ import crypto from "node:crypto"; import type { SerializedProjectCommand } from "./project-command.model.js"; +export const projectBuildingInsertCommandType = "project-building.insert" as const; +export const projectBuildingDeleteCommandType = "project-building.delete" as const; +export const projectBuildingUpdateCommandType = "project-building.update" as const; export const projectFloorInsertCommandType = "project-floor.insert" as const; export const projectFloorDeleteCommandType = "project-floor.delete" as const; export const projectFloorUpdateCommandType = "project-floor.update" as const; export const projectRoomInsertCommandType = "project-room.insert" as const; export const projectRoomDeleteCommandType = "project-room.delete" as const; export const projectRoomUpdateCommandType = "project-room.update" as const; +/** Room commands, building commands and legacy floor commands. */ export const projectLocationStructureCommandSchemaVersion = 1 as const; +/** + * Floor commands carry `buildingId` since version 2. Version 1 floor + * commands stay executable from persisted history: they were recorded before + * buildings existed, so they are upgraded with `buildingId = null`. + */ +export const projectFloorCommandSchemaVersion = 2 as const; +const legacyProjectFloorCommandSchemaVersion = 1 as const; + +export interface ProjectBuildingSnapshot { + id: string; + projectId: string; + name: string; + sortOrder: number; +} export interface ProjectFloorSnapshot { id: string; projectId: string; name: string; sortOrder: number; + buildingId: string | null; } +export type LegacyProjectFloorSnapshot = Omit; + export interface ProjectRoomSnapshot { id: string; projectId: string; @@ -24,39 +45,93 @@ export interface ProjectRoomSnapshot { roomName: string; } +interface ProjectBuildingStructureCommandPayload { + building: ProjectBuildingSnapshot; +} + interface ProjectFloorStructureCommandPayload { floor: ProjectFloorSnapshot; } +interface LegacyProjectFloorStructureCommandPayload { + floor: LegacyProjectFloorSnapshot; +} + interface ProjectRoomStructureCommandPayload { room: ProjectRoomSnapshot; } +interface ProjectBuildingUpdateCommandPayload { + expected: ProjectBuildingSnapshot; + target: ProjectBuildingSnapshot; +} + interface ProjectFloorUpdateCommandPayload { expected: ProjectFloorSnapshot; target: ProjectFloorSnapshot; } +interface LegacyProjectFloorUpdateCommandPayload { + expected: LegacyProjectFloorSnapshot; + target: LegacyProjectFloorSnapshot; +} + interface ProjectRoomUpdateCommandPayload { expected: ProjectRoomSnapshot; target: ProjectRoomSnapshot; } +export interface ProjectBuildingInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectBuildingInsertCommandType; +} + +export interface ProjectBuildingDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectBuildingDeleteCommandType; +} + +export interface ProjectBuildingUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + type: typeof projectBuildingUpdateCommandType; +} + export interface ProjectFloorInsertProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorInsertCommandType; } export interface ProjectFloorDeleteProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectFloorCommandSchemaVersion; type: typeof projectFloorDeleteCommandType; } export interface ProjectFloorUpdateProjectCommand extends SerializedProjectCommand { - schemaVersion: typeof projectLocationStructureCommandSchemaVersion; + schemaVersion: typeof projectFloorCommandSchemaVersion; + type: typeof projectFloorUpdateCommandType; +} + +export interface LegacyProjectFloorInsertProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; + type: typeof projectFloorInsertCommandType; +} + +export interface LegacyProjectFloorDeleteProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; + type: typeof projectFloorDeleteCommandType; +} + +export interface LegacyProjectFloorUpdateProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof legacyProjectFloorCommandSchemaVersion; type: typeof projectFloorUpdateCommandType; } @@ -79,23 +154,46 @@ export interface ProjectRoomUpdateProjectCommand } export type ProjectLocationStructureProjectCommand = + | ProjectBuildingInsertProjectCommand + | ProjectBuildingDeleteProjectCommand + | ProjectBuildingUpdateProjectCommand | ProjectFloorInsertProjectCommand | ProjectFloorDeleteProjectCommand | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorInsertProjectCommand + | LegacyProjectFloorDeleteProjectCommand + | LegacyProjectFloorUpdateProjectCommand | ProjectRoomInsertProjectCommand | ProjectRoomDeleteProjectCommand | ProjectRoomUpdateProjectCommand; -export function createProjectFloorSnapshot( +export function createProjectBuildingSnapshot( projectId: string, name: string, sortOrder: number +): ProjectBuildingSnapshot { + const building: ProjectBuildingSnapshot = { + id: crypto.randomUUID(), + projectId, + name: name.trim(), + sortOrder, + }; + assertProjectBuildingSnapshot(building); + return building; +} + +export function createProjectFloorSnapshot( + projectId: string, + name: string, + sortOrder: number, + buildingId: string | null = null ): ProjectFloorSnapshot { const floor: ProjectFloorSnapshot = { id: crypto.randomUUID(), projectId, name: name.trim(), sortOrder, + buildingId: buildingId?.trim() || null, }; assertProjectFloorSnapshot(floor); return floor; @@ -120,12 +218,46 @@ export function createProjectRoomSnapshot( return room; } +export function createProjectBuildingInsertProjectCommand( + building: ProjectBuildingSnapshot +): ProjectBuildingInsertProjectCommand { + assertProjectBuildingSnapshot(building); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectBuildingInsertCommandType, + payload: { building }, + }; +} + +export function createProjectBuildingDeleteProjectCommand( + building: ProjectBuildingSnapshot +): ProjectBuildingDeleteProjectCommand { + assertProjectBuildingSnapshot(building); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectBuildingDeleteCommandType, + payload: { building }, + }; +} + +export function createProjectBuildingUpdateProjectCommand( + expected: ProjectBuildingSnapshot, + target: ProjectBuildingSnapshot +): ProjectBuildingUpdateProjectCommand { + assertMatchingBuildingUpdate(expected, target); + return { + schemaVersion: projectLocationStructureCommandSchemaVersion, + type: projectBuildingUpdateCommandType, + payload: { expected, target }, + }; +} + export function createProjectFloorInsertProjectCommand( floor: ProjectFloorSnapshot ): ProjectFloorInsertProjectCommand { assertProjectFloorSnapshot(floor); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectFloorCommandSchemaVersion, type: projectFloorInsertCommandType, payload: { floor }, }; @@ -136,7 +268,7 @@ export function createProjectFloorDeleteProjectCommand( ): ProjectFloorDeleteProjectCommand { assertProjectFloorSnapshot(floor); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectFloorCommandSchemaVersion, type: projectFloorDeleteCommandType, payload: { floor }, }; @@ -170,7 +302,7 @@ export function createProjectFloorUpdateProjectCommand( ): ProjectFloorUpdateProjectCommand { assertMatchingFloorUpdate(expected, target); return { - schemaVersion: projectLocationStructureCommandSchemaVersion, + schemaVersion: projectFloorCommandSchemaVersion, type: projectFloorUpdateCommandType, payload: { expected, target }, }; @@ -188,9 +320,43 @@ export function createProjectRoomUpdateProjectCommand( }; } +export function assertProjectBuildingInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectBuildingInsertProjectCommand { + assertProjectBuildingStructureProjectCommand( + command, + projectBuildingInsertCommandType + ); +} + +export function assertProjectBuildingDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectBuildingDeleteProjectCommand { + assertProjectBuildingStructureProjectCommand( + command, + projectBuildingDeleteCommandType + ); +} + +export function assertProjectBuildingUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is ProjectBuildingUpdateProjectCommand { + if ( + command.schemaVersion !== projectLocationStructureCommandSchemaVersion || + command.type !== projectBuildingUpdateCommandType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 2 + ) { + throw new Error("Unsupported project-building update command."); + } + assertMatchingBuildingUpdate(command.payload.expected, command.payload.target); +} + export function assertProjectFloorInsertProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectFloorInsertProjectCommand { +): asserts command is + | ProjectFloorInsertProjectCommand + | LegacyProjectFloorInsertProjectCommand { assertProjectFloorStructureProjectCommand( command, projectFloorInsertCommandType @@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand( export function assertProjectFloorDeleteProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectFloorDeleteProjectCommand { +): asserts command is + | ProjectFloorDeleteProjectCommand + | LegacyProjectFloorDeleteProjectCommand { assertProjectFloorStructureProjectCommand( command, projectFloorDeleteCommandType @@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand( export function assertProjectFloorUpdateProjectCommand( command: SerializedProjectCommand -): asserts command is ProjectFloorUpdateProjectCommand { +): asserts command is + | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorUpdateProjectCommand { if ( - command.schemaVersion !== projectLocationStructureCommandSchemaVersion || command.type !== projectFloorUpdateCommandType || !isPlainObject(command.payload) || Object.keys(command.payload).length !== 2 ) { throw new Error("Unsupported project-floor update command."); } + if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) { + assertMatchingFloorUpdate( + upgradeLegacyFloorSnapshot(command.payload.expected), + upgradeLegacyFloorSnapshot(command.payload.target) + ); + return; + } + if (command.schemaVersion !== projectFloorCommandSchemaVersion) { + throw new Error("Unsupported project-floor update command."); + } assertMatchingFloorUpdate(command.payload.expected, command.payload.target); } @@ -252,6 +431,68 @@ export function assertProjectRoomUpdateProjectCommand( assertMatchingRoomUpdate(command.payload.expected, command.payload.target); } +/** + * Upgrades a (validated) floor command of any supported version to the + * current version. Version 1 commands predate buildings; their floors had no + * building, so `buildingId` becomes null. + */ +export function upgradeProjectFloorCommand( + command: + | ProjectFloorInsertProjectCommand + | LegacyProjectFloorInsertProjectCommand +): ProjectFloorInsertProjectCommand; +export function upgradeProjectFloorCommand( + command: + | ProjectFloorDeleteProjectCommand + | LegacyProjectFloorDeleteProjectCommand +): ProjectFloorDeleteProjectCommand; +export function upgradeProjectFloorCommand( + command: + | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorUpdateProjectCommand +): ProjectFloorUpdateProjectCommand; +export function upgradeProjectFloorCommand( + command: + | ProjectFloorInsertProjectCommand + | ProjectFloorDeleteProjectCommand + | ProjectFloorUpdateProjectCommand + | LegacyProjectFloorInsertProjectCommand + | LegacyProjectFloorDeleteProjectCommand + | LegacyProjectFloorUpdateProjectCommand +) { + if (command.schemaVersion === projectFloorCommandSchemaVersion) { + return command; + } + switch (command.type) { + case projectFloorInsertCommandType: + return createProjectFloorInsertProjectCommand( + upgradeLegacyFloorSnapshot(command.payload.floor) + ); + case projectFloorDeleteCommandType: + return createProjectFloorDeleteProjectCommand( + upgradeLegacyFloorSnapshot(command.payload.floor) + ); + case projectFloorUpdateCommandType: + return createProjectFloorUpdateProjectCommand( + upgradeLegacyFloorSnapshot(command.payload.expected), + upgradeLegacyFloorSnapshot(command.payload.target) + ); + } +} + +function assertMatchingBuildingUpdate(expected: unknown, target: unknown) { + assertProjectBuildingSnapshot(expected); + assertProjectBuildingSnapshot(target); + if ( + expected.id !== target.id || + expected.projectId !== target.projectId || + expected.sortOrder !== target.sortOrder || + expected.name === target.name + ) { + throw new Error("Project-building update must change only the name."); + } +} + function assertMatchingFloorUpdate(expected: unknown, target: unknown) { assertProjectFloorSnapshot(expected); assertProjectFloorSnapshot(target); @@ -259,9 +500,11 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) { expected.id !== target.id || expected.projectId !== target.projectId || expected.sortOrder !== target.sortOrder || - expected.name === target.name + (expected.name === target.name && expected.buildingId === target.buildingId) ) { - throw new Error("Project-floor update must change only the name."); + throw new Error( + "Project-floor update must change only the name or the building." + ); } } @@ -277,22 +520,49 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) { } } +export function assertProjectBuildingSnapshot( + building: unknown +): asserts building is ProjectBuildingSnapshot { + if (!isPlainObject(building) || Object.keys(building).length !== 4) { + throw new Error("Project-building snapshot is invalid."); + } + assertNormalizedNonEmptyString(building.id, "building.id"); + assertNormalizedNonEmptyString(building.projectId, "building.projectId"); + assertNormalizedNonEmptyString(building.name, "building.name"); + assertSortOrder(building.sortOrder, "building.sortOrder"); +} + export function assertProjectFloorSnapshot( floor: unknown ): asserts floor is ProjectFloorSnapshot { + if (!isPlainObject(floor) || Object.keys(floor).length !== 5) { + throw new Error("Project-floor snapshot is invalid."); + } + assertLegacyFloorFields(floor); + if (floor.buildingId !== null) { + assertNormalizedNonEmptyString(floor.buildingId, "floor.buildingId"); + } +} + +function assertLegacyProjectFloorSnapshot( + floor: unknown +): asserts floor is LegacyProjectFloorSnapshot { if (!isPlainObject(floor) || Object.keys(floor).length !== 4) { throw new Error("Project-floor snapshot is invalid."); } + assertLegacyFloorFields(floor); +} + +function assertLegacyFloorFields(floor: Record) { assertNormalizedNonEmptyString(floor.id, "floor.id"); assertNormalizedNonEmptyString(floor.projectId, "floor.projectId"); assertNormalizedNonEmptyString(floor.name, "floor.name"); - if ( - typeof floor.sortOrder !== "number" || - !Number.isSafeInteger(floor.sortOrder) || - floor.sortOrder < 0 - ) { - throw new Error("floor.sortOrder must be a non-negative integer."); - } + assertSortOrder(floor.sortOrder, "floor.sortOrder"); +} + +function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot { + assertLegacyProjectFloorSnapshot(floor); + return { ...floor, buildingId: null }; } export function assertProjectRoomSnapshot( @@ -310,11 +580,11 @@ export function assertProjectRoomSnapshot( assertNormalizedNonEmptyString(room.roomName, "room.roomName"); } -function assertProjectFloorStructureProjectCommand( +function assertProjectBuildingStructureProjectCommand( command: SerializedProjectCommand, expectedType: - | typeof projectFloorInsertCommandType - | typeof projectFloorDeleteCommandType + | typeof projectBuildingInsertCommandType + | typeof projectBuildingDeleteCommandType ) { if ( command.schemaVersion !== projectLocationStructureCommandSchemaVersion || @@ -322,6 +592,29 @@ function assertProjectFloorStructureProjectCommand( !isPlainObject(command.payload) || Object.keys(command.payload).length !== 1 ) { + throw new Error("Unsupported project-building structure command."); + } + assertProjectBuildingSnapshot(command.payload.building); +} + +function assertProjectFloorStructureProjectCommand( + command: SerializedProjectCommand, + expectedType: + | typeof projectFloorInsertCommandType + | typeof projectFloorDeleteCommandType +) { + if ( + command.type !== expectedType || + !isPlainObject(command.payload) || + Object.keys(command.payload).length !== 1 + ) { + throw new Error("Unsupported project-floor structure command."); + } + if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) { + assertLegacyProjectFloorSnapshot(command.payload.floor); + return; + } + if (command.schemaVersion !== projectFloorCommandSchemaVersion) { throw new Error("Unsupported project-floor structure command."); } assertProjectFloorSnapshot(command.payload.floor); @@ -344,6 +637,16 @@ function assertProjectRoomStructureProjectCommand( assertProjectRoomSnapshot(command.payload.room); } +function assertSortOrder(value: unknown, field: string) { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < 0 + ) { + throw new Error(`${field} must be a non-negative integer.`); + } +} + function assertNormalizedNonEmptyString( value: unknown, field: string diff --git a/src/domain/models/project-state-snapshot.model.ts b/src/domain/models/project-state-snapshot.model.ts index 5564e1f..07db42d 100644 --- a/src/domain/models/project-state-snapshot.model.ts +++ b/src/domain/models/project-state-snapshot.model.ts @@ -35,7 +35,8 @@ import { type ExternalModelStateSnapshot, } from "../../external-model/domain/external-model-contracts.js"; -export const projectStateSnapshotSchemaVersion = 6 as const; +export const projectStateSnapshotSchemaVersion = 7 as const; +const coverProjectStateSnapshotSchemaVersion = 6 as const; const boardInfoProjectStateSnapshotSchemaVersion = 5 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const; const externalModelProjectStateSnapshotSchemaVersion = 3 as const; @@ -237,7 +238,7 @@ const projectDeviceSchema = z }) .strict(); -const floorSchema = z +const buildingSchema = z .object({ id: idSchema, projectId: idSchema, @@ -246,6 +247,19 @@ const floorSchema = z }) .strict(); +const legacyFloorSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: z.string().trim().min(1), + sortOrder: finiteNumberSchema, + }) + .strict(); + +const floorSchema = legacyFloorSchema + .extend({ buildingId: idSchema.nullable() }) + .strict(); + const roomSchema = z .object({ id: idSchema, @@ -490,11 +504,24 @@ export function parseExternalModelStateSnapshot( return externalModelStateSchema.parse(value); } -const projectStateSnapshotContents = { +// Versions 1-6 have floors without a building and no building list. +const previousProjectStateSnapshotContents = { circuitLists: z.array(circuitListSchema), circuitSections: z.array(circuitSectionSchema), circuits: z.array(circuitSchema), projectDevices: z.array(projectDeviceSchema), + floors: z.array(legacyFloorSchema), + rooms: z.array(roomSchema), + distributionBoardComponents: z.array(distributionBoardComponentSchema), + circuitProtectionDevices: z.array(circuitProtectionDeviceSchema), + distributionBoardComponentProtectionDevices: z.array( + componentProtectionDeviceSchema + ), +}; + +const projectStateSnapshotContents = { + ...previousProjectStateSnapshotContents, + buildings: z.array(buildingSchema), floors: z.array(floorSchema), rooms: z.array(roomSchema), distributionBoardComponents: z.array(distributionBoardComponentSchema), @@ -515,6 +542,17 @@ export const projectStateSnapshotSchema = z }) .strict(); +const coverProjectStateSnapshotSchema = z + .object({ + schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion), + project: projectSchema, + distributionBoards: z.array(distributionBoardSchema), + externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), + externalModel: externalModelStateSchema, + ...previousProjectStateSnapshotContents, + }) + .strict(); + const boardInfoProjectStateSnapshotSchema = z .object({ schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion), @@ -522,7 +560,7 @@ const boardInfoProjectStateSnapshotSchema = z distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -533,7 +571,7 @@ const previousProjectStateSnapshotSchema = z distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalModel: externalModelStateSchema, - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -543,7 +581,7 @@ const externalModelProjectStateSnapshotSchema = z project: publicBuildingProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -552,7 +590,7 @@ const legacyProjectStateSnapshotSchema = z schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), project: publicBuildingProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -561,7 +599,7 @@ const baselineProjectStateSnapshotSchema = z schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion), project: previousProjectSchema, distributionBoards: z.array(legacyDistributionBoardSchema), - ...projectStateSnapshotContents, + ...previousProjectStateSnapshotContents, }) .strict(); @@ -595,6 +633,28 @@ export function parseProjectStateSnapshot( } function upgradePreviousProjectStateSnapshot(value: unknown): unknown { + const cover = upgradeToCoverProjectStateSnapshot(value); + if ( + cover === null || + typeof cover !== "object" || + Array.isArray(cover) || + (cover as { schemaVersion?: unknown }).schemaVersion !== + coverProjectStateSnapshotSchemaVersion + ) { + return cover; + } + // Every supported version before 7 lacks buildings; its floors had no + // building. + const previous = coverProjectStateSnapshotSchema.parse(cover); + return { + ...previous, + schemaVersion: projectStateSnapshotSchemaVersion, + buildings: [], + floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })), + }; +} + +function upgradeToCoverProjectStateSnapshot(value: unknown): unknown { if ( value === null || typeof value !== "object" || @@ -611,7 +671,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = boardInfoProjectStateSnapshotSchema.parse(value); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...previous.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( previous.distributionBoards @@ -622,7 +682,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = previousProjectStateSnapshotSchema.parse(value); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...previous.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( previous.distributionBoards @@ -633,7 +693,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const previous = externalModelProjectStateSnapshotSchema.parse(value); return { ...previous, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...previous.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( previous.distributionBoards @@ -645,7 +705,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const legacy = legacyProjectStateSnapshotSchema.parse(value); return { ...legacy, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...legacy.project, ...emptyProjectCoverValues }, distributionBoards: withEmptyDistributionBoardInfo( legacy.distributionBoards @@ -658,7 +718,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown { const baseline = baselineProjectStateSnapshotSchema.parse(value); return { ...baseline, - schemaVersion: projectStateSnapshotSchemaVersion, + schemaVersion: coverProjectStateSnapshotSchemaVersion, project: { ...baseline.project, isPublicBuilding: false, @@ -765,6 +825,7 @@ function assertProjectStateSnapshotRelations( snapshot.projectDevices, "project device" ); + const buildingIds = uniqueIds(snapshot.buildings, "building"); const floorIds = uniqueIds(snapshot.floors, "floor"); const roomIds = uniqueIds(snapshot.rooms, "room"); const circuitIds = uniqueIds(snapshot.circuits, "circuit"); @@ -843,8 +904,14 @@ function assertProjectStateSnapshotRelations( ); } } + for (const building of snapshot.buildings) { + assertProjectOwnership(building.projectId, projectId, "building"); + } for (const floor of snapshot.floors) { assertProjectOwnership(floor.projectId, projectId, "floor"); + if (floor.buildingId !== null) { + assertReference(buildingIds, floor.buildingId, "floor building"); + } } for (const room of snapshot.rooms) { assertProjectOwnership(room.projectId, projectId, "room"); diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index 3f28f51..24c0a9c 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -84,6 +84,7 @@ export function remapProjectState( createId ); const deviceIds = idMap(source.projectDevices, createId); + const buildingIds = idMap(source.buildings, createId); const floorIds = idMap(source.floors, createId); const roomIds = idMap(source.rooms, createId); const rowIds = new Map( @@ -242,10 +243,19 @@ export function remapProjectState( id: requiredId(deviceIds, device.id), projectId: targetProjectId, })), + buildings: source.buildings.map((building) => ({ + ...building, + id: requiredId(buildingIds, building.id), + projectId: targetProjectId, + })), floors: source.floors.map((floor) => ({ ...floor, id: requiredId(floorIds, floor.id), projectId: targetProjectId, + buildingId: + floor.buildingId === null + ? null + : requiredId(buildingIds, floor.buildingId), })), rooms: source.rooms.map((room) => ({ ...room, diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index bee31a3..91ee754 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -70,12 +70,18 @@ import { type SerializedProjectCommand, } from "../models/project-command.model.js"; import { + assertProjectBuildingDeleteProjectCommand, + assertProjectBuildingInsertProjectCommand, + assertProjectBuildingUpdateProjectCommand, assertProjectFloorDeleteProjectCommand, assertProjectFloorInsertProjectCommand, assertProjectFloorUpdateProjectCommand, assertProjectRoomDeleteProjectCommand, assertProjectRoomInsertProjectCommand, assertProjectRoomUpdateProjectCommand, + projectBuildingDeleteCommandType, + projectBuildingInsertCommandType, + projectBuildingUpdateCommandType, projectFloorDeleteCommandType, projectFloorInsertCommandType, projectFloorUpdateCommandType, @@ -492,6 +498,18 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case projectBuildingInsertCommandType: { + assertProjectBuildingInsertProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } + case projectBuildingDeleteCommandType: { + assertProjectBuildingDeleteProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } + case projectBuildingUpdateCommandType: { + assertProjectBuildingUpdateProjectCommand(input.command); + return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision; + } case projectFloorInsertCommandType: { assertProjectFloorInsertProjectCommand(input.command); return this.projectLocationStructureStore.execute({ diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index b3cbb4f..a94378e 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -7,14 +7,21 @@ import { listProjects } from "../utils/api"; import type { ProjectDto } from "../types"; import { ThemeToggle } from "./ThemeToggle"; +// Anchors on the project overview page (/projects/[id]). const PROJECT_SECTIONS = [ { id: "verlauf", label: "Verlauf", icon: "↺" }, { id: "verteilungen", label: "Verteilungen", icon: "⌁" }, - { id: "etagen", label: "Etagen", icon: "▥" }, - { id: "raeume", label: "Räume", icon: "⌂" }, { id: "projektgeraete", label: "Projektgeräte", icon: "⚙" }, ] as const; +// Separate project sub-routes (/projects/[id]/). +const PROJECT_ROUTES = [ + { segment: "struktur", label: "Gebäudestruktur", icon: "▥" }, +] as const; + +// Matches the project overview and all of its sub-routes (group 2 = rest). +const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/; + interface SidebarProps { isCollapsed: boolean; onToggleCollapsed: () => void; @@ -22,8 +29,14 @@ interface SidebarProps { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { const pathname = usePathname(); - const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/); + const projectMatch = pathname?.match(PROJECT_PATH_PATTERN); const activeProjectId = projectMatch ? projectMatch[1] : null; + const projectSubPath = projectMatch?.[2] ?? ""; + // Section anchors only exist (and are observed) on the overview itself. + const overviewProjectId = + activeProjectId && (projectSubPath === "" || projectSubPath === "/") + ? activeProjectId + : null; const [projects, setProjects] = useState([]); const [activeSection, setActiveSection] = useState(null); @@ -42,7 +55,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { }, []); useEffect(() => { - if (!activeProjectId) { + if (!overviewProjectId) { setActiveSection(null); return; } @@ -69,7 +82,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { elements.forEach((element) => observer.observe(element)); return () => observer.disconnect(); - }, [activeProjectId]); + }, [overviewProjectId]); return (
    diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 49bb77f..5c14f5e 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -226,11 +226,24 @@ export interface CircuitListDto { name: string; } +export interface BuildingDto { + id: string; + projectId: string; + name: string; + sortOrder: number; +} + +export interface ProjectBuildingCommandResultDto + extends ProjectCommandResultDto { + building: BuildingDto; +} + export interface FloorDto { id: string; projectId: string; name: string; sortOrder: number; + buildingId: string | null; } export interface ProjectFloorCommandResultDto @@ -288,8 +301,14 @@ export interface ProjectDeviceCommandResultDto projectDevice: ProjectDeviceDto; } +export interface CreateBuildingInput { + name: string; +} + export interface CreateFloorInput { name: string; + /** Omitted = keep the current assignment on update; null = no building. */ + buildingId?: string | null; } export interface CreateRoomInput { diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index d55a915..e1f4608 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -1,5 +1,7 @@ import type { + BuildingDto, CircuitListDto, + CreateBuildingInput, CreateFloorInput, CreateProjectDeviceInput, CreateRoomInput, @@ -17,6 +19,7 @@ import type { ProjectCommandResultDto, ProjectDeviceCommandResultDto, ProjectDeviceDto, + ProjectBuildingCommandResultDto, ProjectFloorCommandResultDto, ProjectHistoryStateDto, ProjectRevisionPageDto, @@ -881,6 +884,61 @@ export function renumberCircuitSectionCommand( ); } +export function listBuildings(projectId: string) { + return request(`/api/projects/${projectId}/buildings`); +} + +export function createBuilding( + projectId: string, + input: CreateBuildingInput, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/buildings`, + { + method: "POST", + body: JSON.stringify({ name: input.name, expectedRevision }), + } + ); +} + +export function updateBuilding( + projectId: string, + buildingId: string, + input: CreateBuildingInput, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/buildings/${buildingId}`, + { + method: "PUT", + body: JSON.stringify({ name: input.name, expectedRevision }), + } + ); +} + +export function deleteBuilding( + projectId: string, + buildingId: string, + expectedRevision: number +) { + return request( + `/api/projects/${projectId}/buildings/${buildingId}`, + { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + } + ); +} + +// buildingId is only sent when the caller passes it (string or null); +// an omitted buildingId keeps the current assignment on update. +function toFloorRequestBody(input: CreateFloorInput, expectedRevision: number) { + return input.buildingId === undefined + ? { name: input.name, expectedRevision } + : { name: input.name, buildingId: input.buildingId, expectedRevision }; +} + export function listFloors(projectId: string) { return request(`/api/projects/${projectId}/floors`); } @@ -894,7 +952,7 @@ export function createFloor( `/api/projects/${projectId}/floors`, { method: "POST", - body: JSON.stringify({ ...input, expectedRevision }), + body: JSON.stringify(toFloorRequestBody(input, expectedRevision)), } ); } @@ -907,7 +965,7 @@ export function updateFloor( ) { return request(`/api/projects/${projectId}/floors/${floorId}`, { method: "PUT", - body: JSON.stringify({ ...input, expectedRevision }), + body: JSON.stringify(toFloorRequestBody(input, expectedRevision)), }); } diff --git a/src/frontend/utils/building-structure.ts b/src/frontend/utils/building-structure.ts new file mode 100644 index 0000000..0c9d6ab --- /dev/null +++ b/src/frontend/utils/building-structure.ts @@ -0,0 +1,284 @@ +// Pure selection, filtering and grouping rules for the building structure +// view (Gebäude → Etagen → Räume) and grouped floor selects. +import type { BuildingDto, FloorDto, RoomDto } from "../types"; + +/** Selection key for "Alle" in the building and floor columns. */ +export const ALL_SELECTION = "__all__"; +/** Selection key for "Ohne Gebäude" / "Ohne Etage". */ +export const UNASSIGNED_SELECTION = "__unassigned__"; + +/** ALL_SELECTION, UNASSIGNED_SELECTION or a building/floor id. */ +export type StructureSelection = string; + +export const UNASSIGNED_BUILDING_LABEL = "Ohne Gebäude"; +export const UNASSIGNED_FLOOR_LABEL = "Ohne Etage"; + +type BuildingLike = Pick; +type FloorLike = Pick & { + buildingId?: string | null; +}; +type RoomLike = Pick; + +export interface FloorGroup { + /** null for floors without (known) building. */ + buildingId: string | null; + label: string; + floors: TFloor[]; +} + +/** + * The building a floor belongs to, or null when it has none or references a + * building that is not (or no longer) loaded. + */ +export function resolveFloorBuildingId( + floor: FloorLike, + buildingIds: ReadonlySet +): string | null { + const buildingId = floor.buildingId ?? null; + return buildingId !== null && buildingIds.has(buildingId) + ? buildingId + : null; +} + +function toIdSet(entries: ReadonlyArray<{ id: string }>) { + return new Set(entries.map((entry) => entry.id)); +} + +export function hasFloorsWithoutBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray +): boolean { + const buildingIds = toIdSet(buildings); + return floors.some( + (floor) => resolveFloorBuildingId(floor, buildingIds) === null + ); +} + +export function hasRoomsWithoutFloor( + rooms: ReadonlyArray, + floors: ReadonlyArray +): boolean { + const floorIds = toIdSet(floors); + return rooms.some( + (room) => room.floorId === null || !floorIds.has(room.floorId) + ); +} + +/** + * Groups floors by building in building order; floors without a known + * building come last under "Ohne Gebäude". Empty groups are omitted and the + * floor order inside a group is preserved. + */ +export function groupFloorsByBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray +): FloorGroup[] { + const buildingIds = toIdSet(buildings); + const floorsByBuilding = new Map(); + for (const floor of floors) { + const buildingId = resolveFloorBuildingId(floor, buildingIds); + const group = floorsByBuilding.get(buildingId) ?? []; + group.push(floor); + floorsByBuilding.set(buildingId, group); + } + const groups: FloorGroup[] = []; + for (const building of buildings) { + const groupFloors = floorsByBuilding.get(building.id); + if (groupFloors?.length) { + groups.push({ + buildingId: building.id, + label: building.name, + floors: groupFloors, + }); + } + } + const unassigned = floorsByBuilding.get(null); + if (unassigned?.length) { + groups.push({ + buildingId: null, + label: UNASSIGNED_BUILDING_LABEL, + floors: unassigned, + }); + } + return groups; +} + +/** Floors shown in the floor column for a building selection. */ +export function filterFloorsByBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray, + buildingSelection: StructureSelection +): TFloor[] { + if (buildingSelection === ALL_SELECTION) { + return [...floors]; + } + const buildingIds = toIdSet(buildings); + const targetBuildingId = + buildingSelection === UNASSIGNED_SELECTION ? null : buildingSelection; + return floors.filter( + (floor) => resolveFloorBuildingId(floor, buildingIds) === targetBuildingId + ); +} + +/** + * "Ohne Etage" is offered only when rooms without floor exist and the + * building selection can contain them ("Alle" or "Ohne Gebäude"), because a + * room without floor has no building either. + */ +export function showsRoomsWithoutFloorEntry( + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildingSelection: StructureSelection +): boolean { + return ( + (buildingSelection === ALL_SELECTION || + buildingSelection === UNASSIGNED_SELECTION) && + hasRoomsWithoutFloor(rooms, floors) + ); +} + +/** Rooms shown in the room column for the current selections. */ +export function filterRooms( + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildings: ReadonlyArray, + buildingSelection: StructureSelection, + floorSelection: StructureSelection +): TRoom[] { + const floorIds = toIdSet(floors); + const isWithoutFloor = (room: RoomLike) => + room.floorId === null || !floorIds.has(room.floorId); + + if (floorSelection === UNASSIGNED_SELECTION) { + return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection) + ? rooms.filter(isWithoutFloor) + : []; + } + if (floorSelection !== ALL_SELECTION) { + return rooms.filter((room) => room.floorId === floorSelection); + } + if (buildingSelection === ALL_SELECTION) { + return [...rooms]; + } + const visibleFloorIds = toIdSet( + filterFloorsByBuilding(floors, buildings, buildingSelection) + ); + return rooms.filter( + (room) => + (room.floorId !== null && visibleFloorIds.has(room.floorId)) || + (buildingSelection === UNASSIGNED_SELECTION && isWithoutFloor(room)) + ); +} + +/** Falls back to "Alle" when a selected building vanished. */ +export function normalizeBuildingSelection( + selection: StructureSelection, + floors: ReadonlyArray, + buildings: ReadonlyArray +): StructureSelection { + if (selection === ALL_SELECTION) { + return selection; + } + if (selection === UNASSIGNED_SELECTION) { + return hasFloorsWithoutBuilding(floors, buildings) + ? selection + : ALL_SELECTION; + } + return buildings.some((building) => building.id === selection) + ? selection + : ALL_SELECTION; +} + +/** Falls back to "Alle" when a selected floor is no longer visible. */ +export function normalizeFloorSelection( + selection: StructureSelection, + rooms: ReadonlyArray, + floors: ReadonlyArray, + buildings: ReadonlyArray, + buildingSelection: StructureSelection +): StructureSelection { + if (selection === ALL_SELECTION) { + return selection; + } + if (selection === UNASSIGNED_SELECTION) { + return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection) + ? selection + : ALL_SELECTION; + } + return filterFloorsByBuilding(floors, buildings, buildingSelection).some( + (floor) => floor.id === selection + ) + ? selection + : ALL_SELECTION; +} + +/** Building preselected in the floor dialog ("" = Ohne Gebäude). */ +export function defaultBuildingIdForNewFloor( + buildingSelection: StructureSelection +): string { + return buildingSelection === ALL_SELECTION || + buildingSelection === UNASSIGNED_SELECTION + ? "" + : buildingSelection; +} + +/** Floor preselected in the room dialog ("" = Ohne Etage). */ +export function defaultFloorIdForNewRoom( + floorSelection: StructureSelection +): string { + return floorSelection === ALL_SELECTION || + floorSelection === UNASSIGNED_SELECTION + ? "" + : floorSelection; +} + +export function countFloorsByBuilding( + floors: ReadonlyArray, + buildings: ReadonlyArray +): Map { + const buildingIds = toIdSet(buildings); + const counts = new Map(); + for (const floor of floors) { + const buildingId = resolveFloorBuildingId(floor, buildingIds); + counts.set(buildingId, (counts.get(buildingId) ?? 0) + 1); + } + return counts; +} + +/** Room counts per floor id; rooms without known floor count under null. */ +export function countRoomsByFloor( + rooms: ReadonlyArray, + floors: ReadonlyArray +): Map { + const floorIds = toIdSet(floors); + const counts = new Map(); + for (const room of rooms) { + const floorId = + room.floorId !== null && floorIds.has(room.floorId) + ? room.floorId + : null; + counts.set(floorId, (counts.get(floorId) ?? 0) + 1); + } + return counts; +} + +/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */ +export function describeRoomLocation( + room: RoomLike, + floors: ReadonlyArray, + buildings: ReadonlyArray +): string { + const floor = + room.floorId === null + ? undefined + : floors.find((entry) => entry.id === room.floorId); + if (!floor) { + return UNASSIGNED_FLOOR_LABEL; + } + const buildingId = resolveFloorBuildingId(floor, toIdSet(buildings)); + const building = + buildingId === null + ? undefined + : buildings.find((entry) => entry.id === buildingId); + return building ? `${floor.name} · ${building.name}` : floor.name; +} diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 5685b29..a2880ec 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -46,6 +46,9 @@ const commandTypeLabels: Record = { "circuit.move-group": "Stromkreis in andere Gruppe verschoben", "circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt", "circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt", + "project-building.insert": "Gebäude angelegt", + "project-building.update": "Gebäude bearbeitet", + "project-building.delete": "Gebäude entfernt", "project-floor.insert": "Geschoss angelegt", "project-floor.update": "Geschoss bearbeitet", "project-floor.delete": "Geschoss entfernt", diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 944c843..4f9b5ee 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -6,6 +6,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js"; import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; +import { BuildingRepository } from "../../db/repositories/building.repository.js"; import { FloorRepository } from "../../db/repositories/floor.repository.js"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; @@ -26,6 +27,7 @@ export const distributionBoardComponentRepository = new DistributionBoardComponentRepository(db); export const distributionBoardRepository = new DistributionBoardRepository(db); +export const buildingRepository = new BuildingRepository(db); export const floorRepository = new FloorRepository(db); export const globalDeviceRepository = new GlobalDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db); diff --git a/src/server/controllers/building.controller.ts b/src/server/controllers/building.controller.ts new file mode 100644 index 0000000..3a49120 --- /dev/null +++ b/src/server/controllers/building.controller.ts @@ -0,0 +1,111 @@ +import type { Request, Response } from "express"; +import { + createProjectBuildingDeleteProjectCommand, + createProjectBuildingInsertProjectCommand, + createProjectBuildingSnapshot, + createProjectBuildingUpdateProjectCommand, +} from "../../domain/models/project-location-structure-project-command.model.js"; +import { + createBuildingSchema, + deleteProjectLocationSchema, + updateBuildingSchema, +} from "../../shared/validation/project-structure.schemas.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { buildingRepository } from "../composition/application-repositories.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +export async function listBuildingsByProject(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const result = await buildingRepository.listByProject(projectId); + return res.json(result); +} + +export async function createBuilding(req: Request, res: Response) { + const { projectId } = req.params; + if (typeof projectId !== "string") { + return res.status(400).json({ error: "Invalid projectId" }); + } + + const parsed = createBuildingSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + + const existingBuildings = await buildingRepository.listByProject(projectId); + const nextSortOrder = existingBuildings.length + ? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1 + : 0; + const building = createProjectBuildingSnapshot( + projectId, + parsed.data.name, + nextSortOrder + ); + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Gebäude anlegen", + command: createProjectBuildingInsertProjectCommand(building), + }); + return res.status(201).json({ ...result, building }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function updateBuilding(req: Request, res: Response) { + const { projectId, buildingId } = req.params; + if (typeof projectId !== "string" || typeof buildingId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = updateBuildingSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await buildingRepository.findById(projectId, buildingId); + if (!current) { + return res.status(404).json({ error: "Building not found" }); + } + const target = { ...current, name: parsed.data.name }; + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Gebäude bearbeiten", + command: createProjectBuildingUpdateProjectCommand(current, target), + }); + return res.json({ ...result, building: target }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export async function deleteBuilding(req: Request, res: Response) { + const { projectId, buildingId } = req.params; + if (typeof projectId !== "string" || typeof buildingId !== "string") { + return res.status(400).json({ error: "Invalid parameters" }); + } + const parsed = deleteProjectLocationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = await buildingRepository.findById(projectId, buildingId); + if (!current) { + return res.status(404).json({ error: "Building not found" }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Gebäude löschen", + command: createProjectBuildingDeleteProjectCommand(current), + }); + return res.json(result); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} diff --git a/src/server/controllers/floor.controller.ts b/src/server/controllers/floor.controller.ts index 085aa10..b000db3 100644 --- a/src/server/controllers/floor.controller.ts +++ b/src/server/controllers/floor.controller.ts @@ -42,7 +42,8 @@ export async function createFloor(req: Request, res: Response) { const floor = createProjectFloorSnapshot( projectId, parsed.data.name, - nextSortOrder + nextSortOrder, + parsed.data.buildingId ?? null ); try { const result = projectCommandService.executeUser({ @@ -70,7 +71,14 @@ export async function updateFloor(req: Request, res: Response) { if (!current) { return res.status(404).json({ error: "Floor not found" }); } - const target = { ...current, name: parsed.data.name }; + const target = { + ...current, + name: parsed.data.name, + buildingId: + parsed.data.buildingId === undefined + ? current.buildingId + : parsed.data.buildingId, + }; try { const result = projectCommandService.executeUser({ projectId, diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 8801784..0b61255 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -13,6 +13,12 @@ import { updateDistributionBoard, } from "../controllers/distribution-board.controller.js"; import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; +import { + createBuilding, + deleteBuilding, + listBuildingsByProject, + updateBuilding, +} from "../controllers/building.controller.js"; import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js"; import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; @@ -100,6 +106,10 @@ projectRouter.get( "/:projectId/circuit-sections/:sectionId/next-identifier", getNextCircuitIdentifier ); +projectRouter.get("/:projectId/buildings", listBuildingsByProject); +projectRouter.post("/:projectId/buildings", createBuilding); +projectRouter.put("/:projectId/buildings/:buildingId", updateBuilding); +projectRouter.delete("/:projectId/buildings/:buildingId", deleteBuilding); projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.post("/:projectId/floors", createFloor); projectRouter.put("/:projectId/floors/:floorId", updateFloor); diff --git a/src/shared/validation/project-structure.schemas.ts b/src/shared/validation/project-structure.schemas.ts index 143be0a..5ab13bf 100644 --- a/src/shared/validation/project-structure.schemas.ts +++ b/src/shared/validation/project-structure.schemas.ts @@ -102,10 +102,22 @@ export const deleteDistributionBoardSchema = z }) .strict(); +export const createBuildingSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200), + }) + .strict(); + +export const updateBuildingSchema = createBuildingSchema; + +// A missing buildingId keeps the current value (older clients send only the +// name); null removes the floor from its building. export const createFloorSchema = z .object({ expectedRevision: expectedProjectRevisionSchema, name: z.string().trim().min(1).max(200), + buildingId: z.string().trim().min(1).nullable().optional(), }) .strict(); diff --git a/tests/building-structure-frontend-api.test.ts b/tests/building-structure-frontend-api.test.ts new file mode 100644 index 0000000..9e23150 --- /dev/null +++ b/tests/building-structure-frontend-api.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + createBuilding, + createFloor, + deleteBuilding, + listBuildings, + updateBuilding, + updateFloor, +} from "../src/frontend/utils/api.js"; + +describe("building structure frontend API", () => { + it("sends building commands and optional floor building assignments", async () => { + const requests: Array<{ url: string; method: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + }); + return new Response(JSON.stringify([]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + await listBuildings("project-1"); + await createBuilding("project-1", { name: "Haus A" }, 3); + await updateBuilding("project-1", "building-1", { name: "Haus B" }, 4); + await deleteBuilding("project-1", "building-1", 5); + await createFloor( + "project-1", + { name: "EG", buildingId: "building-1" }, + 6 + ); + await createFloor("project-1", { name: "Dach", buildingId: null }, 7); + await createFloor("project-1", { name: "UG", buildingId: undefined }, 8); + await updateFloor( + "project-1", + "floor-1", + { name: "1. OG", buildingId: null }, + 9 + ); + await updateFloor("project-1", "floor-1", { name: "2. OG" }, 10); + } finally { + globalThis.fetch = originalFetch; + } + + assert.deepEqual(requests, [ + { + url: "/api/projects/project-1/buildings", + method: "GET", + body: undefined, + }, + { + url: "/api/projects/project-1/buildings", + method: "POST", + body: { name: "Haus A", expectedRevision: 3 }, + }, + { + url: "/api/projects/project-1/buildings/building-1", + method: "PUT", + body: { name: "Haus B", expectedRevision: 4 }, + }, + { + url: "/api/projects/project-1/buildings/building-1", + method: "DELETE", + body: { expectedRevision: 5 }, + }, + { + url: "/api/projects/project-1/floors", + method: "POST", + body: { name: "EG", buildingId: "building-1", expectedRevision: 6 }, + }, + { + url: "/api/projects/project-1/floors", + method: "POST", + body: { name: "Dach", buildingId: null, expectedRevision: 7 }, + }, + { + url: "/api/projects/project-1/floors", + method: "POST", + body: { name: "UG", expectedRevision: 8 }, + }, + { + url: "/api/projects/project-1/floors/floor-1", + method: "PUT", + body: { name: "1. OG", buildingId: null, expectedRevision: 9 }, + }, + { + url: "/api/projects/project-1/floors/floor-1", + method: "PUT", + body: { name: "2. OG", expectedRevision: 10 }, + }, + ]); + const updateWithoutBuilding = requests[8].body as Record; + assert.equal("buildingId" in updateWithoutBuilding, false); + }); +}); diff --git a/tests/building-structure-view.test.ts b/tests/building-structure-view.test.ts new file mode 100644 index 0000000..59271af --- /dev/null +++ b/tests/building-structure-view.test.ts @@ -0,0 +1,284 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { + BuildingDto, + FloorDto, + RoomDto, +} from "../src/frontend/types.js"; +import { + ALL_SELECTION, + UNASSIGNED_SELECTION, + countFloorsByBuilding, + countRoomsByFloor, + defaultBuildingIdForNewFloor, + defaultFloorIdForNewRoom, + describeRoomLocation, + filterFloorsByBuilding, + filterRooms, + groupFloorsByBuilding, + hasFloorsWithoutBuilding, + hasRoomsWithoutFloor, + normalizeBuildingSelection, + normalizeFloorSelection, + showsRoomsWithoutFloorEntry, +} from "../src/frontend/utils/building-structure.js"; + +function building(id: string, name: string, sortOrder: number): BuildingDto { + return { id, projectId: "project-1", name, sortOrder }; +} + +function floor( + id: string, + name: string, + buildingId: string | null, + sortOrder = 0 +): FloorDto { + return { id, projectId: "project-1", name, sortOrder, buildingId }; +} + +function room(id: string, floorId: string | null, roomNumber = id): RoomDto { + return { + id, + projectId: "project-1", + floorId, + roomNumber, + roomName: `Raum ${roomNumber}`, + }; +} + +const buildings = [building("b-a", "Haus A", 0), building("b-b", "Haus B", 1)]; +const floors = [ + floor("f-free", "Außenanlage", null, 0), + floor("f-a-eg", "EG", "b-a", 1), + floor("f-b-eg", "EG", "b-b", 2), + floor("f-a-og", "1. OG", "b-a", 3), + floor("f-orphan", "Keller", "b-deleted", 4), +]; +const rooms = [ + room("r-a-eg", "f-a-eg"), + room("r-a-og", "f-a-og"), + room("r-b-eg", "f-b-eg"), + room("r-free", "f-free"), + room("r-orphan-floor", "f-orphan"), + room("r-none", null), + room("r-missing-floor", "f-deleted"), +]; + +const ids = (entries: ReadonlyArray<{ id: string }>) => + entries.map((entry) => entry.id); + +describe("building structure view", () => { + it("groups floors in building order and puts unassigned floors last", () => { + const groups = groupFloorsByBuilding(floors, buildings); + assert.deepEqual( + groups.map((group) => ({ + buildingId: group.buildingId, + label: group.label, + floors: ids(group.floors), + })), + [ + { buildingId: "b-a", label: "Haus A", floors: ["f-a-eg", "f-a-og"] }, + { buildingId: "b-b", label: "Haus B", floors: ["f-b-eg"] }, + { + buildingId: null, + label: "Ohne Gebäude", + floors: ["f-free", "f-orphan"], + }, + ] + ); + }); + + it("omits empty groups", () => { + const groups = groupFloorsByBuilding( + [floor("f-1", "EG", "b-b")], + buildings + ); + assert.deepEqual( + groups.map((group) => group.buildingId), + ["b-b"] + ); + assert.deepEqual(groupFloorsByBuilding([], buildings), []); + }); + + it("filters floors by building selection", () => { + assert.deepEqual( + ids(filterFloorsByBuilding(floors, buildings, ALL_SELECTION)), + ids(floors) + ); + assert.deepEqual( + ids(filterFloorsByBuilding(floors, buildings, "b-a")), + ["f-a-eg", "f-a-og"] + ); + assert.deepEqual( + ids(filterFloorsByBuilding(floors, buildings, UNASSIGNED_SELECTION)), + ["f-free", "f-orphan"] + ); + }); + + it("detects floors without building and rooms without floor", () => { + assert.equal(hasFloorsWithoutBuilding(floors, buildings), true); + assert.equal( + hasFloorsWithoutBuilding([floor("f-1", "EG", "b-a")], buildings), + false + ); + assert.equal(hasRoomsWithoutFloor(rooms, floors), true); + assert.equal(hasRoomsWithoutFloor([room("r-1", "f-a-eg")], floors), false); + }); + + it("filters rooms for building and floor selections", () => { + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, ALL_SELECTION)), + ids(rooms) + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, "b-a", ALL_SELECTION)), + ["r-a-eg", "r-a-og"] + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, "b-a", "f-a-og")), + ["r-a-og"] + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, "f-b-eg")), + ["r-b-eg"] + ); + assert.deepEqual( + ids( + filterRooms( + rooms, + floors, + buildings, + UNASSIGNED_SELECTION, + ALL_SELECTION + ) + ), + ["r-free", "r-orphan-floor", "r-none", "r-missing-floor"] + ); + assert.deepEqual( + ids( + filterRooms( + rooms, + floors, + buildings, + ALL_SELECTION, + UNASSIGNED_SELECTION + ) + ), + ["r-none", "r-missing-floor"] + ); + assert.deepEqual( + ids(filterRooms(rooms, floors, buildings, "b-a", UNASSIGNED_SELECTION)), + [] + ); + }); + + it("offers 'Ohne Etage' only where rooms without floor can belong", () => { + assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, ALL_SELECTION), true); + assert.equal( + showsRoomsWithoutFloorEntry(rooms, floors, UNASSIGNED_SELECTION), + true + ); + assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, "b-a"), false); + assert.equal( + showsRoomsWithoutFloorEntry([room("r-1", "f-a-eg")], floors, ALL_SELECTION), + false + ); + }); + + it("falls back to 'Alle' for vanished or hidden selections", () => { + assert.equal(normalizeBuildingSelection("b-a", floors, buildings), "b-a"); + assert.equal( + normalizeBuildingSelection("b-deleted", floors, buildings), + ALL_SELECTION + ); + assert.equal( + normalizeBuildingSelection(UNASSIGNED_SELECTION, floors, buildings), + UNASSIGNED_SELECTION + ); + assert.equal( + normalizeBuildingSelection( + UNASSIGNED_SELECTION, + [floor("f-1", "EG", "b-a")], + buildings + ), + ALL_SELECTION + ); + + assert.equal( + normalizeFloorSelection("f-a-og", rooms, floors, buildings, "b-a"), + "f-a-og" + ); + assert.equal( + normalizeFloorSelection("f-b-eg", rooms, floors, buildings, "b-a"), + ALL_SELECTION + ); + assert.equal( + normalizeFloorSelection("f-deleted", rooms, floors, buildings, ALL_SELECTION), + ALL_SELECTION + ); + assert.equal( + normalizeFloorSelection( + UNASSIGNED_SELECTION, + rooms, + floors, + buildings, + ALL_SELECTION + ), + UNASSIGNED_SELECTION + ); + assert.equal( + normalizeFloorSelection(UNASSIGNED_SELECTION, rooms, floors, buildings, "b-a"), + ALL_SELECTION + ); + }); + + it("preselects the selected building and floor for new entries", () => { + assert.equal(defaultBuildingIdForNewFloor("b-a"), "b-a"); + assert.equal(defaultBuildingIdForNewFloor(ALL_SELECTION), ""); + assert.equal(defaultBuildingIdForNewFloor(UNASSIGNED_SELECTION), ""); + assert.equal(defaultFloorIdForNewRoom("f-a-eg"), "f-a-eg"); + assert.equal(defaultFloorIdForNewRoom(ALL_SELECTION), ""); + assert.equal(defaultFloorIdForNewRoom(UNASSIGNED_SELECTION), ""); + }); + + it("counts floors per building and rooms per floor", () => { + assert.deepEqual( + [...countFloorsByBuilding(floors, buildings)], + [ + [null, 2], + ["b-a", 2], + ["b-b", 1], + ] + ); + assert.deepEqual( + [...countRoomsByFloor(rooms, floors)], + [ + ["f-a-eg", 1], + ["f-a-og", 1], + ["f-b-eg", 1], + ["f-free", 1], + ["f-orphan", 1], + [null, 2], + ] + ); + }); + + it("describes a room location with floor and building", () => { + assert.equal( + describeRoomLocation(room("r", "f-a-og"), floors, buildings), + "1. OG · Haus A" + ); + assert.equal( + describeRoomLocation(room("r", "f-free"), floors, buildings), + "Außenanlage" + ); + assert.equal( + describeRoomLocation(room("r", "f-orphan"), floors, buildings), + "Keller" + ); + assert.equal( + describeRoomLocation(room("r", null), floors, buildings), + "Ohne Etage" + ); + }); +}); diff --git a/tests/project-building-structure.test.ts b/tests/project-building-structure.test.ts new file mode 100644 index 0000000..2c9ccdf --- /dev/null +++ b/tests/project-building-structure.test.ts @@ -0,0 +1,305 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js"; +import { buildings } from "../src/db/schema/buildings.js"; +import { floors } from "../src/db/schema/floors.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + assertProjectFloorInsertProjectCommand, + createProjectBuildingDeleteProjectCommand, + createProjectBuildingInsertProjectCommand, + createProjectBuildingSnapshot, + createProjectBuildingUpdateProjectCommand, + createProjectFloorInsertProjectCommand, + createProjectFloorSnapshot, + createProjectFloorUpdateProjectCommand, + projectFloorCommandSchemaVersion, + type ProjectLocationStructureProjectCommand, +} from "../src/domain/models/project-location-structure-project-command.model.js"; +import { + createBuildingSchema, + createFloorSchema, + updateFloorSchema, +} from "../src/shared/validation/project-structure.schemas.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Test project" }, + { id: "project-2", name: "Foreign project" }, + ]) + .run(); + return context; +} + +/** Executes a user command and returns a runner for its undo and redo. */ +function createRunner(context: DatabaseContext) { + const repository = new ProjectLocationStructureProjectCommandRepository( + context.db + ); + const history = new ProjectHistoryRepository(context.db); + let revision = 0; + const run = ( + command: ProjectLocationStructureProjectCommand, + source: "user" | "undo" | "redo" = "user" + ) => { + const step = + source === "user" ? null : history.getNextCommand("project-1", source); + if (source !== "user") { + assert.ok(step); + } + const result = repository.execute({ + projectId: "project-1", + expectedRevision: revision, + source, + historyTargetChangeSetId: step?.changeSetId, + command, + }); + revision += 1; + return result; + }; + return { repository, run }; +} + +function getBuilding(context: DatabaseContext, id: string) { + return context.db.select().from(buildings).where(eq(buildings.id, id)).get(); +} + +function getFloor(context: DatabaseContext, id: string) { + return context.db.select().from(floors).where(eq(floors.id, id)).get(); +} + +describe("project building structure", () => { + it("creates, renames and deletes buildings with persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + const building = createProjectBuildingSnapshot("project-1", " Haus A ", 0); + assert.equal(building.name, "Haus A"); + + const insert = createProjectBuildingInsertProjectCommand(building); + const inserted = run(insert); + assert.deepEqual(getBuilding(context, building.id), building); + + const renamed = { ...building, name: "Haus B" }; + const update = createProjectBuildingUpdateProjectCommand(building, renamed); + const updated = run(update); + assert.equal(getBuilding(context, building.id)?.name, "Haus B"); + + run(updated.inverse, "undo"); + assert.deepEqual(getBuilding(context, building.id), building); + run(inserted.inverse, "undo"); + assert.equal(getBuilding(context, building.id), undefined); + run(insert, "redo"); + run(update, "redo"); + assert.deepEqual(getBuilding(context, building.id), renamed); + + const deleted = run(createProjectBuildingDeleteProjectCommand(renamed)); + assert.equal(getBuilding(context, building.id), undefined); + run(deleted.inverse, "undo"); + assert.deepEqual(getBuilding(context, building.id), renamed); + } finally { + context.close(); + } + }); + + it("assigns floors to buildings and blocks deleting a building in use", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + const houseA = createProjectBuildingSnapshot("project-1", "Haus A", 0); + const houseB = createProjectBuildingSnapshot("project-1", "Haus B", 1); + run(createProjectBuildingInsertProjectCommand(houseA)); + run(createProjectBuildingInsertProjectCommand(houseB)); + + const floor = createProjectFloorSnapshot("project-1", "EG", 0, houseA.id); + const floorInsert = createProjectFloorInsertProjectCommand(floor); + assert.equal(floorInsert.schemaVersion, projectFloorCommandSchemaVersion); + run(floorInsert); + assert.deepEqual(getFloor(context, floor.id), floor); + + const moved = { ...floor, buildingId: houseB.id }; + const moveCommand = createProjectFloorUpdateProjectCommand(floor, moved); + const movedResult = run(moveCommand); + assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id); + + assert.throws( + () => run(createProjectBuildingDeleteProjectCommand(houseB)), + /solange Etagen zugeordnet/ + ); + assert.ok(getBuilding(context, houseB.id)); + + run(movedResult.inverse, "undo"); + assert.equal(getFloor(context, floor.id)?.buildingId, houseA.id); + run(moveCommand, "redo"); + assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id); + + const unassigned = { ...moved, buildingId: null }; + run(createProjectFloorUpdateProjectCommand(moved, unassigned)); + assert.equal(getFloor(context, floor.id)?.buildingId, null); + run(createProjectBuildingDeleteProjectCommand(houseB)); + assert.equal(getBuilding(context, houseB.id), undefined); + } finally { + context.close(); + } + }); + + it("rejects floors that reference a missing or foreign building", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + context.db + .insert(buildings) + .values({ id: "foreign", projectId: "project-2", name: "Fremd", sortOrder: 0 }) + .run(); + assert.throws( + () => + run( + createProjectFloorInsertProjectCommand( + createProjectFloorSnapshot("project-1", "EG", 0, "foreign") + ) + ), + /foreign building/ + ); + assert.throws( + () => + run( + createProjectFloorInsertProjectCommand( + createProjectFloorSnapshot("project-1", "EG", 0, "missing") + ) + ), + /foreign building/ + ); + assert.equal(context.db.select().from(floors).all().length, 0); + + const floor = createProjectFloorSnapshot("project-1", "EG", 0); + run(createProjectFloorInsertProjectCommand(floor)); + assert.throws( + () => + run( + createProjectFloorUpdateProjectCommand(floor, { + ...floor, + buildingId: "foreign", + }) + ), + /foreign building/ + ); + assert.throws( + () => createProjectFloorUpdateProjectCommand(floor, { ...floor }), + /only the name or the building/ + ); + assert.throws( + () => + createProjectFloorUpdateProjectCommand(floor, { + ...floor, + sortOrder: 5, + name: "Erdgeschoss", + }), + /only the name or the building/ + ); + } finally { + context.close(); + } + }); + + it("executes version 1 floor commands recorded before buildings existed", () => { + const context = createTestDatabase(); + try { + const { run } = createRunner(context); + const legacyFloor = { + id: "floor-legacy", + projectId: "project-1", + name: "EG", + sortOrder: 0, + }; + const legacyInsert = { + schemaVersion: 1 as const, + type: "project-floor.insert" as const, + payload: { floor: legacyFloor }, + }; + assertProjectFloorInsertProjectCommand(legacyInsert); + const inserted = run(legacyInsert); + assert.deepEqual(getFloor(context, legacyFloor.id), { + ...legacyFloor, + buildingId: null, + }); + // The inverse is always written in the current version. + assert.equal(inserted.inverse.schemaVersion, projectFloorCommandSchemaVersion); + + const legacyUpdate = { + schemaVersion: 1 as const, + type: "project-floor.update" as const, + payload: { + expected: legacyFloor, + target: { ...legacyFloor, name: "Erdgeschoss" }, + }, + }; + const updated = run(legacyUpdate); + assert.equal(getFloor(context, legacyFloor.id)?.name, "Erdgeschoss"); + run(updated.inverse, "undo"); + assert.equal(getFloor(context, legacyFloor.id)?.name, "EG"); + + // A version 1 snapshot must not carry buildingId, a version 2 one must. + assert.throws(() => + assertProjectFloorInsertProjectCommand({ + schemaVersion: 1, + type: "project-floor.insert", + payload: { floor: { ...legacyFloor, buildingId: null } }, + }) + ); + assert.throws(() => + assertProjectFloorInsertProjectCommand({ + schemaVersion: 2, + type: "project-floor.insert", + payload: { floor: legacyFloor }, + }) + ); + } finally { + context.close(); + } + }); + + it("validates building and floor requests", () => { + assert.equal( + createBuildingSchema.safeParse({ expectedRevision: 0, name: "Haus A" }).success, + true + ); + assert.equal( + createBuildingSchema.safeParse({ expectedRevision: 0, name: " " }).success, + false + ); + assert.equal( + createBuildingSchema.safeParse({ + expectedRevision: 0, + name: "Haus A", + sortOrder: 1, + }).success, + false + ); + for (const buildingId of [undefined, null, "building-1"]) { + assert.equal( + createFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId }) + .success, + true + ); + } + assert.equal( + updateFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId: "" }) + .success, + false + ); + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 861edcd..5630cb8 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -41,6 +41,7 @@ import { projectChangeSets } from "../src/db/schema/project-change-sets.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; +import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { rooms } from "../src/db/schema/rooms.js"; import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; @@ -78,6 +79,8 @@ import { createDistributionBoardComponentUpdateProjectCommand, } from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; import { + createProjectBuildingInsertProjectCommand, + createProjectBuildingSnapshot, createProjectFloorInsertProjectCommand, createProjectFloorSnapshot, createProjectRoomInsertProjectCommand, @@ -1610,6 +1613,43 @@ describe("project command service", () => { } }); + it("dispatches building commands and floor assignments with persisted inverses", () => { + const context = createTestDatabase(); + try { + const service = createService(context); + const building = createProjectBuildingSnapshot("project-1", "Haus A", 0); + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createProjectBuildingInsertProjectCommand(building), + }); + const floor = createProjectFloorSnapshot("project-1", "EG", 0, building.id); + const created = service.executeUser({ + projectId: "project-1", + expectedRevision: 1, + command: createProjectFloorInsertProjectCommand(floor), + }); + assert.equal(created.history.currentRevision, 2); + assert.equal( + context.db.select().from(floors).where(eq(floors.id, floor.id)).get() + ?.buildingId, + building.id + ); + + createService(context).undo({ projectId: "project-1", expectedRevision: 2 }); + createService(context).undo({ projectId: "project-1", expectedRevision: 3 }); + assert.equal(context.db.select().from(buildings).all().length, 0); + createService(context).redo({ projectId: "project-1", expectedRevision: 4 }); + createService(context).redo({ projectId: "project-1", expectedRevision: 5 }); + assert.deepEqual( + context.db.select().from(floors).where(eq(floors.id, floor.id)).get(), + floor + ); + } finally { + context.close(); + } + }); + it("dispatches floor and room setup with their persisted inverses", () => { const context = createTestDatabase(); try { diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index 449864a..0ed798d 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -20,6 +20,7 @@ import { circuits } from "../src/db/schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { buildings } from "../src/db/schema/buildings.js"; import { floors } from "../src/db/schema/floors.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; @@ -59,6 +60,15 @@ function createTestDatabase(): DatabaseContext { .where(eq(circuitSections.circuitListId, list.id)) .get(); assert.ok(section); + context.db + .insert(buildings) + .values({ + id: "building-1", + projectId: "project-1", + name: "Haus A", + sortOrder: 0, + }) + .run(); context.db .insert(floors) .values({ @@ -66,6 +76,7 @@ function createTestDatabase(): DatabaseContext { projectId: "project-1", name: "EG", sortOrder: 10, + buildingId: "building-1", }) .run(); context.db @@ -248,7 +259,9 @@ describe("project snapshot repository", () => { "device-1" ); assert.equal(state.projectDevices.length, 1); + assert.equal(state.buildings.length, 1); assert.equal(state.floors.length, 1); + assert.equal(state.floors[0].buildingId, "building-1"); assert.equal(state.rooms.length, 1); assert.deepEqual(repository.listByProject("project-1"), [ created, @@ -400,8 +413,14 @@ describe("project snapshot repository", () => { schemaVersion: number; project: Record; distributionBoards: Array>; + buildings?: unknown; + floors: Array>; }; payload.schemaVersion = 5; + delete payload.buildings; + for (const floor of payload.floors) { + delete floor.buildingId; + } for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete payload.project[field]; } @@ -434,6 +453,9 @@ describe("project snapshot repository", () => { (board) => board.boardNumber === null && board.fedFrom === null ) ); + // Version 5 predates buildings: the upgraded state has none. + assert.deepEqual(targetState.buildings, []); + assert.ok(targetState.floors.every((floor) => floor.buildingId === null)); } finally { context.close(); } @@ -497,6 +519,11 @@ describe("portable project transfer", () => { copied.state.distributionBoards[0].floorId, copied.state.floors[0].id ); + assert.notEqual(copied.state.buildings[0].id, "building-1"); + assert.equal( + copied.state.floors[0].buildingId, + copied.state.buildings[0].id + ); assert.equal( copied.state.distributionBoards[0].supplyType, "SV" diff --git a/tests/project-state-snapshot.test.ts b/tests/project-state-snapshot.test.ts index 04d4901..c75555f 100644 --- a/tests/project-state-snapshot.test.ts +++ b/tests/project-state-snapshot.test.ts @@ -8,10 +8,22 @@ import { } from "../src/domain/models/project-state-snapshot.model.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; -/** Removes the fields introduced by snapshot schema version 6. */ -function withoutVersionSixFields< - T extends { project: object; distributionBoards: object[] }, +/** Removes the fields introduced by snapshot schema version 7. */ +function withoutVersionSevenFields< + T extends { floors: object[] }, >(snapshot: T): T { + delete (snapshot as Record).buildings; + for (const floor of snapshot.floors) { + delete (floor as Record).buildingId; + } + return snapshot; +} + +/** Removes the fields introduced by snapshot schema versions 6 and 7. */ +function withoutVersionSixFields< + T extends { project: object; distributionBoards: object[]; floors: object[] }, +>(snapshot: T): T { + withoutVersionSevenFields(snapshot); const project = snapshot.project as Record; for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) { delete project[field]; @@ -66,7 +78,19 @@ function minimalSnapshot() { distributionBoardComponentProtectionDevices: [], circuits: [], projectDevices: [], - floors: [], + buildings: [] as Array<{ + id: string; + projectId: string; + name: string; + sortOrder: number; + }>, + floors: [] as Array<{ + id: string; + projectId: string; + name: string; + sortOrder: number; + buildingId: string | null; + }>, rooms: [], }; } @@ -306,7 +330,10 @@ describe("project state snapshot model", () => { delete (outdated.project as Record).description; assert.throws(() => parseProjectStateSnapshot(outdated)); assert.throws(() => - parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 }) + parseProjectStateSnapshot({ + ...minimalSnapshot(), + schemaVersion: projectStateSnapshotSchemaVersion + 1, + }) ); }); @@ -355,12 +382,99 @@ describe("project state snapshot model", () => { projectId: "project-2", name: "EG", sortOrder: 10, + buildingId: null, }); assert.throws( () => parseProjectStateSnapshot(wrongOwner), /floor belongs to a different project/ ); }); + + it("keeps buildings and validates floor building references", () => { + const snapshot = minimalSnapshot(); + snapshot.buildings.push({ + id: "building-1", + projectId: "project-1", + name: "Haus A", + sortOrder: 0, + }); + snapshot.floors.push( + { + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + buildingId: "building-1", + }, + { + id: "floor-2", + projectId: "project-1", + name: "Keller", + sortOrder: 1, + buildingId: null, + } + ); + const parsed = deserializeProjectStateSnapshot( + serializeProjectStateSnapshot(parseProjectStateSnapshot(snapshot)) + ); + assert.deepEqual(parsed.buildings, snapshot.buildings); + assert.deepEqual( + parsed.floors.map((floor) => floor.buildingId), + ["building-1", null] + ); + + const unknownBuilding = structuredClone(snapshot); + unknownBuilding.floors[0]!.buildingId = "building-x"; + assert.throws( + () => parseProjectStateSnapshot(unknownBuilding), + /floor building/ + ); + + const foreignBuilding = structuredClone(snapshot); + foreignBuilding.buildings[0]!.projectId = "project-2"; + assert.throws( + () => parseProjectStateSnapshot(foreignBuilding), + /building belongs to a different project/ + ); + + const floorWithoutBuildingField = structuredClone(snapshot); + delete (floorWithoutBuildingField.floors[1] as Record) + .buildingId; + assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField)); + }); + + it("upgrades snapshot version 6 without buildings", () => { + const snapshot = minimalSnapshot(); + snapshot.floors.push({ + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + buildingId: null, + }); + const previous = withoutVersionSevenFields(structuredClone(snapshot)); + previous.schemaVersion = 6 as typeof previous.schemaVersion; + + const upgraded = parseProjectStateSnapshot(previous); + + assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); + assert.deepEqual(upgraded.buildings, []); + assert.deepEqual(upgraded.floors, [ + { + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 0, + buildingId: null, + }, + ]); + // Version 6 cover values survive the upgrade. + assert.equal(upgraded.project.workStage, "LPH 5"); + + const mislabeled = structuredClone(snapshot); + mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion; + assert.throws(() => parseProjectStateSnapshot(mislabeled)); + }); }); describe("named project snapshot request", () => { From 5f4d9bf7898450772c02584756049a7cd624b136 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 12:57:50 +0200 Subject: [PATCH 2/2] Show the sidebar on every page except the print view AppShell showed the sidebar only on the project list and overview, so it disappeared on the building structure page and in the circuit list editor. It is collapsible, so it now stays on every page. Only the print view, a stand-alone document opened in its own tab and printed as PDF, keeps the full width. Co-Authored-By: Claude Opus 5.5 --- src/app/globals.css | 2 +- src/frontend/components/AppShell.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index f392303..0c40c5c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -220,7 +220,7 @@ a { --bs-alert-border-color: #205a38; } -/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */ +/* ── App-Shell / Seitenleiste (alle Seiten außer der Seitenansicht) ──── */ .app-shell { display: grid; diff --git a/src/frontend/components/AppShell.tsx b/src/frontend/components/AppShell.tsx index bbfa0ae..77b7c78 100644 --- a/src/frontend/components/AppShell.tsx +++ b/src/frontend/components/AppShell.tsx @@ -4,12 +4,14 @@ import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { Sidebar } from "./Sidebar"; -const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/; +// Every page shows the (collapsible) sidebar except the print view, which is +// a stand-alone document opened in its own tab and printed as PDF. +const PRINT_VIEW_PATTERN = /^\/projects\/[^/]+\/print(\/|$)/; const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed"; export function AppShell({ children }: { children: React.ReactNode }) { const pathname = usePathname(); - const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? ""); + const showSidebar = !PRINT_VIEW_PATTERN.test(pathname ?? ""); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); useEffect(() => {