forked from jappel/leistungsbilanz-ts
Add buildings above floors and a building structure page
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 <noreply@anthropic.com>
This commit is contained in:
parent
7692699ee2
commit
cd89c4a499
42 changed files with 5883 additions and 373 deletions
15
AGENTS.md
15
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
79
docs/building-structure.md
Normal file
79
docs/building-structure.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ProjectDeviceSyncField, string> = {
|
||||
name: "Technischer Name",
|
||||
|
|
@ -89,6 +86,7 @@ export default function ProjectDetailPage() {
|
|||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
||||
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
|
||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||
|
|
@ -116,14 +114,9 @@ export default function ProjectDetailPage() {
|
|||
remark: null,
|
||||
})
|
||||
);
|
||||
const [floorName, setFloorName] = useState("");
|
||||
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
|
||||
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(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<HTMLFormElement>) {
|
||||
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() {
|
|||
<div className="kpi-label">Verteilungen</div>
|
||||
<div className="kpi-value">{boardCount}</div>
|
||||
</a>
|
||||
<a className="kpi" href="#etagen">
|
||||
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
|
||||
<div className="kpi-label">Gebäude</div>
|
||||
<div className="kpi-value">{buildingCount}</div>
|
||||
</Link>
|
||||
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
|
||||
<div className="kpi-label">Etagen</div>
|
||||
<div className="kpi-value">{floorCount}</div>
|
||||
</a>
|
||||
<a className="kpi" href="#raeume">
|
||||
</Link>
|
||||
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
|
||||
<div className="kpi-label">Räume</div>
|
||||
<div className="kpi-value">{roomCount}</div>
|
||||
</a>
|
||||
</Link>
|
||||
<a className="kpi" href="#projektgeraete">
|
||||
<div className="kpi-label">Projektgeräte</div>
|
||||
<div className="kpi-value">{projectDeviceCount}</div>
|
||||
|
|
@ -1025,88 +899,6 @@ export default function ProjectDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-5" id="etagen">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span>Etagen</span>
|
||||
<span className="badge text-bg-secondary ms-2">{floorCount}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={openCreateFloor}
|
||||
type="button"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<ul className="list-group list-group-flush">
|
||||
{floors.map((floor) => (
|
||||
<li className="list-group-item d-flex justify-content-between align-items-center gap-2" key={floor.id}>
|
||||
<span>{floor.name}</span>
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditFloor(floor)} type="button">
|
||||
Bearbeiten
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{!floors.length ? (
|
||||
<li className="list-group-item text-secondary">Noch keine Etagen vorhanden.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-7" id="raeume">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span>Räume</span>
|
||||
<span className="badge text-bg-secondary ms-2">{roomCount}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={openCreateRoom}
|
||||
type="button"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Raumnummer</th>
|
||||
<th>Raumname</th>
|
||||
<th>Etage</th>
|
||||
<th className="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((room) => (
|
||||
<tr key={room.id}>
|
||||
<td>{room.roomNumber}</td>
|
||||
<td>{room.roomName}</td>
|
||||
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
|
||||
<td className="text-end">
|
||||
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditRoom(room)} type="button">
|
||||
Bearbeiten
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!rooms.length ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center text-secondary py-4">
|
||||
Noch keine Räume vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card shadow-sm mt-4" id="projektgeraete">
|
||||
|
|
@ -1366,11 +1158,7 @@ export default function ProjectDetailPage() {
|
|||
value={boardFloorId}
|
||||
>
|
||||
<option value="">Ohne Etage</option>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
|
|
@ -1436,11 +1224,7 @@ export default function ProjectDetailPage() {
|
|||
value={editingBoardFloorId}
|
||||
>
|
||||
<option value="">Ohne Etage</option>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
|
|
@ -1572,57 +1356,6 @@ export default function ProjectDetailPage() {
|
|||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{structureModal === "floor" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => {
|
||||
setStructureModal(null);
|
||||
setEditingFloor(null);
|
||||
}}
|
||||
onSubmit={handleSaveFloor}
|
||||
submitDisabled={!floorName.trim()}
|
||||
submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"}
|
||||
title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
||||
>
|
||||
<label className="form-label" htmlFor="floor-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="floor-name"
|
||||
onChange={(event) => setFloorName(event.target.value)}
|
||||
placeholder="z. B. EG"
|
||||
required
|
||||
value={floorName}
|
||||
/>
|
||||
{editingFloor ? (
|
||||
<div className="border-top mt-4 pt-3">
|
||||
<p className="text-secondary small mb-2">
|
||||
Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden.
|
||||
</p>
|
||||
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteFloor()} type="button">
|
||||
Etage löschen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{structureModal === "room" ? (
|
||||
<RoomModal
|
||||
floors={floors}
|
||||
initialRoom={editingRoom ?? undefined}
|
||||
isSaving={isSaving}
|
||||
onClose={() => {
|
||||
setStructureModal(null);
|
||||
setEditingRoom(null);
|
||||
}}
|
||||
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isProjectDeviceModalOpen ? (
|
||||
<ProjectDeviceModal
|
||||
globalDevices={globalDevices}
|
||||
|
|
|
|||
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createBuilding,
|
||||
createFloor,
|
||||
createRoom,
|
||||
deleteBuilding,
|
||||
deleteFloor,
|
||||
deleteRoom,
|
||||
getProject,
|
||||
listBuildings,
|
||||
listFloors,
|
||||
listRooms,
|
||||
updateBuilding,
|
||||
updateFloor,
|
||||
updateRoom,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
BuildingDto,
|
||||
CreateRoomInput,
|
||||
FloorDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
} from "../../../../frontend/types";
|
||||
import {
|
||||
ALL_SELECTION,
|
||||
UNASSIGNED_BUILDING_LABEL,
|
||||
UNASSIGNED_FLOOR_LABEL,
|
||||
UNASSIGNED_SELECTION,
|
||||
countFloorsByBuilding,
|
||||
countRoomsByFloor,
|
||||
defaultBuildingIdForNewFloor,
|
||||
defaultFloorIdForNewRoom,
|
||||
describeRoomLocation,
|
||||
filterFloorsByBuilding,
|
||||
filterRooms,
|
||||
groupFloorsByBuilding,
|
||||
hasFloorsWithoutBuilding,
|
||||
normalizeBuildingSelection,
|
||||
normalizeFloorSelection,
|
||||
showsRoomsWithoutFloorEntry,
|
||||
type StructureSelection,
|
||||
} from "../../../../frontend/utils/building-structure";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||
|
||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||
|
||||
type StructureDialog =
|
||||
| { kind: "building"; building: BuildingDto | null }
|
||||
| { kind: "floor"; floor: FloorDto | null }
|
||||
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
|
||||
|
||||
function formatCount(count: number, singular: string, plural: string) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
||||
return (
|
||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||
left.roomName.localeCompare(right.roomName, "de")
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureColumnProps {
|
||||
addLabel: string;
|
||||
children: ReactNode;
|
||||
count: number;
|
||||
headingId: string;
|
||||
isAddDisabled: boolean;
|
||||
onAdd: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function StructureColumn({
|
||||
addLabel,
|
||||
children,
|
||||
count,
|
||||
headingId,
|
||||
isAddDisabled,
|
||||
onAdd,
|
||||
title,
|
||||
}: StructureColumnProps) {
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<h2 className="structure-column-title" id={headingId}>
|
||||
{title}
|
||||
<span className="badge text-bg-secondary ms-2">{count}</span>
|
||||
</h2>
|
||||
<button
|
||||
aria-label={addLabel}
|
||||
className="btn btn-sm btn-outline-primary structure-icon-button"
|
||||
disabled={isAddDisabled}
|
||||
onClick={onAdd}
|
||||
title={addLabel}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="list-group list-group-flush structure-list">{children}</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureEntryProps {
|
||||
actions?: ReactNode;
|
||||
hint?: string;
|
||||
isSelected?: boolean;
|
||||
label: ReactNode;
|
||||
meta?: string;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
function StructureEntry({
|
||||
actions,
|
||||
hint,
|
||||
isSelected = false,
|
||||
label,
|
||||
meta,
|
||||
onSelect,
|
||||
}: StructureEntryProps) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="structure-entry-text">
|
||||
<span className="structure-entry-label">{label}</span>
|
||||
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
|
||||
</span>
|
||||
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
isSelected
|
||||
? "list-group-item structure-entry is-selected"
|
||||
: "list-group-item structure-entry"
|
||||
}
|
||||
>
|
||||
{onSelect ? (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className="structure-entry-main"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="structure-entry-main">{content}</div>
|
||||
)}
|
||||
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface EntryActionsProps {
|
||||
deleteLabel: string;
|
||||
editLabel: string;
|
||||
isDisabled: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function EntryActions({
|
||||
deleteLabel,
|
||||
editLabel,
|
||||
isDisabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: EntryActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={editLabel}
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onEdit}
|
||||
title={editLabel}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={deleteLabel}
|
||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onDelete}
|
||||
title={deleteLabel}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildingStructurePage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||
const [buildingSelection, setBuildingSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [floorSelection, setFloorSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [dialog, setDialog] = useState<StructureDialog | null>(null);
|
||||
const [dialogName, setDialogName] = useState("");
|
||||
const [dialogBuildingId, setDialogBuildingId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const reloadStructure = useCallback(async () => {
|
||||
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
|
||||
await Promise.all([
|
||||
getProject(projectId),
|
||||
listBuildings(projectId),
|
||||
listFloors(projectId),
|
||||
listRooms(projectId),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setBuildings(loadedBuildings);
|
||||
setFloors(loadedFloors);
|
||||
setRooms(loadedRooms);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadStructure()
|
||||
.then(() => setError(null))
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Gebäudestruktur konnte nicht geladen werden."
|
||||
)
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId, reloadStructure]);
|
||||
|
||||
const effectiveBuildingSelection = normalizeBuildingSelection(
|
||||
buildingSelection,
|
||||
floors,
|
||||
buildings
|
||||
);
|
||||
const effectiveFloorSelection = normalizeFloorSelection(
|
||||
floorSelection,
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
|
||||
const visibleFloors = useMemo(
|
||||
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
|
||||
[buildings, effectiveBuildingSelection, floors]
|
||||
);
|
||||
const visibleFloorGroups = useMemo(
|
||||
() =>
|
||||
effectiveBuildingSelection === ALL_SELECTION && buildings.length
|
||||
? groupFloorsByBuilding(visibleFloors, buildings)
|
||||
: [{ buildingId: null, label: "", floors: visibleFloors }],
|
||||
[buildings, effectiveBuildingSelection, visibleFloors]
|
||||
);
|
||||
const visibleRooms = useMemo(
|
||||
() =>
|
||||
filterRooms(
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection,
|
||||
effectiveFloorSelection
|
||||
).sort(compareRooms),
|
||||
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
|
||||
);
|
||||
const roomsInBuildingSelection = useMemo(
|
||||
() =>
|
||||
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
|
||||
.length,
|
||||
[buildings, effectiveBuildingSelection, floors, rooms]
|
||||
);
|
||||
const floorCountByBuilding = useMemo(
|
||||
() => countFloorsByBuilding(floors, buildings),
|
||||
[buildings, floors]
|
||||
);
|
||||
const roomCountByFloor = useMemo(
|
||||
() => countRoomsByFloor(rooms, floors),
|
||||
[floors, rooms]
|
||||
);
|
||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
||||
rooms,
|
||||
floors,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
const isBusy = isSaving || !project;
|
||||
|
||||
function selectBuilding(selection: StructureSelection) {
|
||||
setBuildingSelection(selection);
|
||||
setFloorSelection(ALL_SELECTION);
|
||||
}
|
||||
|
||||
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||
execute: (expectedRevision: number) => Promise<TResult>,
|
||||
fallbackMessage: string
|
||||
): Promise<boolean> {
|
||||
if (!project) {
|
||||
return false;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await execute(project.currentRevision);
|
||||
setProject((current) =>
|
||||
current
|
||||
? { ...current, currentRevision: result.history.currentRevision }
|
||||
: current
|
||||
);
|
||||
await reloadStructure();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||
// Refresh the revision and lists so a stale state does not block the
|
||||
// next attempt; the command error stays visible.
|
||||
await reloadStructure().catch(() => undefined);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBuildingDialog(building: BuildingDto | null) {
|
||||
setDialogName(building?.name ?? "");
|
||||
setDialog({ kind: "building", building });
|
||||
}
|
||||
|
||||
function openFloorDialog(floor: FloorDto | null) {
|
||||
setDialogName(floor?.name ?? "");
|
||||
setDialogBuildingId(
|
||||
floor
|
||||
? floor.buildingId ?? ""
|
||||
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
|
||||
);
|
||||
setDialog({ kind: "floor", floor });
|
||||
}
|
||||
|
||||
function openRoomDialog(room: RoomDto | null) {
|
||||
setDialog({
|
||||
kind: "room",
|
||||
room,
|
||||
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "building" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingBuilding = dialog.building;
|
||||
const name = dialogName.trim();
|
||||
// An unchanged dialog is not a command; the backend rejects no-op updates.
|
||||
if (editingBuilding && editingBuilding.name === name) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingBuilding
|
||||
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
|
||||
: createBuilding(projectId, { name }, expectedRevision),
|
||||
"Gebäude konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "floor" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingFloor = dialog.floor;
|
||||
const input = {
|
||||
name: dialogName.trim(),
|
||||
buildingId: dialogBuildingId || null,
|
||||
};
|
||||
if (
|
||||
editingFloor &&
|
||||
editingFloor.name === input.name &&
|
||||
editingFloor.buildingId === input.buildingId
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingFloor
|
||||
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
|
||||
: createFloor(projectId, input, expectedRevision),
|
||||
"Etage konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRoom(input: CreateRoomInput) {
|
||||
if (dialog?.kind !== "room") {
|
||||
return;
|
||||
}
|
||||
const editingRoom = dialog.room;
|
||||
if (
|
||||
editingRoom &&
|
||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||
editingRoom.roomName === input.roomName.trim() &&
|
||||
editingRoom.floorId === (input.floorId || null)
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingRoom
|
||||
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
|
||||
: createRoom(projectId, input, expectedRevision),
|
||||
"Raum konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBuilding(building: BuildingDto) {
|
||||
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
|
||||
setError(BUILDING_HAS_FLOORS_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) =>
|
||||
deleteBuilding(projectId, building.id, expectedRevision),
|
||||
"Gebäude konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteFloor(floor: FloorDto) {
|
||||
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
|
||||
"Etage konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteRoom(room: RoomDto) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
|
||||
"Raum konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Projektübersicht"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link"
|
||||
href={`/projects/${projectId}`}
|
||||
title="Zurück zur Projektübersicht"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Gebäudestruktur</h1>
|
||||
<p className="text-secondary mb-0">
|
||||
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Gebäudestruktur wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="structure-columns">
|
||||
<StructureColumn
|
||||
addLabel="Gebäude hinzufügen"
|
||||
count={buildings.length}
|
||||
headingId="structure-buildings-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openBuildingDialog(null)}
|
||||
title="Gebäude"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||
/>
|
||||
{buildings.map((building) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Gebäude „${building.name}“ löschen`}
|
||||
editLabel={`Gebäude „${building.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteBuilding(building)}
|
||||
onEdit={() => openBuildingDialog(building)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveBuildingSelection === building.id}
|
||||
key={building.id}
|
||||
label={building.name}
|
||||
meta={formatCount(
|
||||
floorCountByBuilding.get(building.id) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
)}
|
||||
onSelect={() => selectBuilding(building.id)}
|
||||
/>
|
||||
))}
|
||||
{showUnassignedBuilding ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||
meta={formatCount(
|
||||
floorCountByBuilding.get(null) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
)}
|
||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!buildings.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
Noch keine Gebäude vorhanden.
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Etage hinzufügen"
|
||||
count={visibleFloors.length}
|
||||
headingId="structure-floors-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openFloorDialog(null)}
|
||||
title="Etagen"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||
/>
|
||||
{visibleFloorGroups.map((group) => (
|
||||
<FloorGroupEntries
|
||||
key={group.buildingId ?? "unassigned"}
|
||||
label={group.label}
|
||||
>
|
||||
{group.floors.map((floor) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Etage „${floor.name}“ löschen`}
|
||||
editLabel={`Etage „${floor.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteFloor(floor)}
|
||||
onEdit={() => openFloorDialog(floor)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveFloorSelection === floor.id}
|
||||
key={floor.id}
|
||||
label={floor.name}
|
||||
meta={formatCount(
|
||||
roomCountByFloor.get(floor.id) ?? 0,
|
||||
"Raum",
|
||||
"Räume"
|
||||
)}
|
||||
onSelect={() => setFloorSelection(floor.id)}
|
||||
/>
|
||||
))}
|
||||
</FloorGroupEntries>
|
||||
))}
|
||||
{showUnassignedFloor ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!visibleFloors.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{floors.length
|
||||
? "Keine Etagen in dieser Auswahl."
|
||||
: "Noch keine Etagen vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Raum hinzufügen"
|
||||
count={visibleRooms.length}
|
||||
headingId="structure-rooms-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openRoomDialog(null)}
|
||||
title="Räume"
|
||||
>
|
||||
{visibleRooms.map((room) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
|
||||
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteRoom(room)}
|
||||
onEdit={() => openRoomDialog(room)}
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
effectiveFloorSelection === ALL_SELECTION
|
||||
? describeRoomLocation(room, floors, buildings)
|
||||
: undefined
|
||||
}
|
||||
key={room.id}
|
||||
label={
|
||||
<>
|
||||
<span className="structure-room-number">{room.roomNumber}</span>{" "}
|
||||
{room.roomName}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!visibleRooms.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{rooms.length
|
||||
? "Keine Räume in dieser Auswahl."
|
||||
: "Noch keine Räume vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
</div>
|
||||
|
||||
{dialog?.kind === "building" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveBuilding}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
|
||||
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
|
||||
>
|
||||
<label className="form-label" htmlFor="building-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="building-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. Haus A"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "floor" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveFloor}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
|
||||
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="floor-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. EG"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-building">
|
||||
Gebäude
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="floor-building"
|
||||
onChange={(event) => setDialogBuildingId(event.target.value)}
|
||||
value={dialogBuildingId}
|
||||
>
|
||||
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
|
||||
{buildings.map((building) => (
|
||||
<option key={building.id} value={building.id}>
|
||||
{building.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "room" ? (
|
||||
<RoomModal
|
||||
buildings={buildings}
|
||||
floors={floors}
|
||||
initialRoom={dialog.room ?? undefined}
|
||||
initialValues={
|
||||
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorGroupEntries({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
<li className="list-group-item structure-group-label">
|
||||
{label}
|
||||
</li>
|
||||
) : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
src/db/migrations/0009_buildings.sql
Normal file
9
src/db/migrations/0009_buildings.sql
Normal file
|
|
@ -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;
|
||||
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
27
src/db/repositories/building.repository.ts
Normal file
27
src/db/repositories/building.repository.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
11
src/db/schema/buildings.ts
Normal file
11
src/db/schema/buildings.ts
Normal file
|
|
@ -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),
|
||||
});
|
||||
|
|
@ -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",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<ProjectFloorSnapshot, "buildingId">;
|
||||
|
||||
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<ProjectBuildingStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectBuildingInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectBuildingDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectBuildingUpdateCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectBuildingUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectFloorInsertProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectFloorUpdateProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectFloorUpdateCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorUpdateCommandType;
|
||||
}
|
||||
|
||||
|
|
@ -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<unknown>
|
||||
): asserts command is ProjectBuildingInsertProjectCommand {
|
||||
assertProjectBuildingStructureProjectCommand(
|
||||
command,
|
||||
projectBuildingInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectBuildingDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectBuildingDeleteProjectCommand {
|
||||
assertProjectBuildingStructureProjectCommand(
|
||||
command,
|
||||
projectBuildingDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectBuildingUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectBuildingUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== projectBuildingUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported project-building update command.");
|
||||
}
|
||||
assertMatchingBuildingUpdate(command.payload.expected, command.payload.target);
|
||||
}
|
||||
|
||||
export function assertProjectFloorInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorInsertProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorInsertCommandType
|
||||
|
|
@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand(
|
|||
|
||||
export function assertProjectFloorDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorDeleteProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorDeleteCommandType
|
||||
|
|
@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand(
|
|||
|
||||
export function assertProjectFloorUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): 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<string, unknown>) {
|
||||
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<unknown>,
|
||||
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<unknown>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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]/<segment>).
|
||||
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<ProjectDto[]>([]);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(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 (
|
||||
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
|
||||
|
|
@ -122,17 +135,46 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
{activeProjectId && (
|
||||
<>
|
||||
<div className="sidebar-section-label">In diesem Projekt</div>
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</a>
|
||||
))}
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
|
||||
overviewProjectId ? (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/projects/${activeProjectId}#${id}`}
|
||||
className="sidebar-link"
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{PROJECT_ROUTES.map(({ segment, label, icon }) => {
|
||||
const href = `/projects/${activeProjectId}/${segment}`;
|
||||
const isActive =
|
||||
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
|
||||
return (
|
||||
<Link
|
||||
key={segment}
|
||||
href={href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={isActive ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
|
|
|||
49
src/frontend/components/floor-select-options.tsx
Normal file
49
src/frontend/components/floor-select-options.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { BuildingDto, FloorDto } from "../types";
|
||||
import { groupFloorsByBuilding } from "../utils/building-structure";
|
||||
|
||||
export type FloorOption = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
|
||||
interface FloorSelectOptionsProps {
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor <option>s for a <select>, grouped by building via <optgroup> as soon
|
||||
* as the project has buildings. The caller renders its own empty option
|
||||
* ("Ohne Etage") before these.
|
||||
*/
|
||||
export function FloorSelectOptions({
|
||||
buildings = [],
|
||||
floors,
|
||||
}: FloorSelectOptionsProps) {
|
||||
if (!buildings.length) {
|
||||
return (
|
||||
<>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groupFloorsByBuilding(floors, buildings).map((group) => (
|
||||
<optgroup key={group.buildingId ?? "unassigned"} label={group.label}>
|
||||
{group.floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { type FormEvent, useState } from "react";
|
||||
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
|
||||
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
||||
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface RoomModalProps {
|
||||
floors: FloorDto[];
|
||||
/** When given, the floor select groups floors by building. */
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
initialRoom?: RoomDto;
|
||||
initialValues?: Partial<CreateRoomInput>;
|
||||
isSaving: boolean;
|
||||
|
|
@ -15,6 +18,7 @@ interface RoomModalProps {
|
|||
}
|
||||
|
||||
export function RoomModal({
|
||||
buildings,
|
||||
floors,
|
||||
initialRoom,
|
||||
initialValues,
|
||||
|
|
@ -83,7 +87,7 @@ export function RoomModal({
|
|||
value={floorId}
|
||||
>
|
||||
<option value="">Ohne Etage</option>
|
||||
{floors.map((floor) => <option key={floor.id} value={floor.id}>{floor.name}</option>)}
|
||||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<BuildingDto[]>(`/api/projects/${projectId}/buildings`);
|
||||
}
|
||||
|
||||
export function createBuilding(
|
||||
projectId: string,
|
||||
input: CreateBuildingInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectBuildingCommandResultDto>(
|
||||
`/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<ProjectBuildingCommandResultDto>(
|
||||
`/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<ProjectCommandResultDto>(
|
||||
`/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<FloorDto[]>(`/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<ProjectFloorCommandResultDto>(`/api/projects/${projectId}/floors/${floorId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
body: JSON.stringify(toFloorRequestBody(input, expectedRevision)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
284
src/frontend/utils/building-structure.ts
Normal file
284
src/frontend/utils/building-structure.ts
Normal file
|
|
@ -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<BuildingDto, "id" | "name">;
|
||||
type FloorLike = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
type RoomLike = Pick<RoomDto, "floorId">;
|
||||
|
||||
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
|
||||
/** 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>
|
||||
): 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<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): boolean {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
return floors.some(
|
||||
(floor) => resolveFloorBuildingId(floor, buildingIds) === null
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRoomsWithoutFloor(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>
|
||||
): 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<TFloor extends FloorLike>(
|
||||
floors: ReadonlyArray<TFloor>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): FloorGroup<TFloor>[] {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const floorsByBuilding = new Map<string | null, TFloor[]>();
|
||||
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<TFloor>[] = [];
|
||||
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<TFloor extends FloorLike>(
|
||||
floors: ReadonlyArray<TFloor>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
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<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
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<TRoom extends RoomLike>(
|
||||
rooms: ReadonlyArray<TRoom>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
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<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): 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<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
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<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): Map<string | null, number> {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const counts = new Map<string | null, number>();
|
||||
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<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>
|
||||
): Map<string | null, number> {
|
||||
const floorIds = toIdSet(floors);
|
||||
const counts = new Map<string | null, number>();
|
||||
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<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): 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;
|
||||
}
|
||||
|
|
@ -46,6 +46,9 @@ const commandTypeLabels: Record<string, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
111
src/server/controllers/building.controller.ts
Normal file
111
src/server/controllers/building.controller.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
100
tests/building-structure-frontend-api.test.ts
Normal file
100
tests/building-structure-frontend-api.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
assert.equal("buildingId" in updateWithoutBuilding, false);
|
||||
});
|
||||
});
|
||||
284
tests/building-structure-view.test.ts
Normal file
284
tests/building-structure-view.test.ts
Normal file
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
305
tests/project-building-structure.test.ts
Normal file
305
tests/project-building-structure.test.ts
Normal file
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
distributionBoards: Array<Record<string, unknown>>;
|
||||
buildings?: unknown;
|
||||
floors: Array<Record<string, unknown>>;
|
||||
};
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).buildings;
|
||||
for (const floor of snapshot.floors) {
|
||||
delete (floor as Record<string, unknown>).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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>)
|
||||
.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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue