forked from jappel/leistungsbilanz-ts
Add optional room areas
This commit is contained in:
parent
a6500e2b26
commit
dce31ee143
26 changed files with 3669 additions and 78 deletions
25
AGENTS.md
25
AGENTS.md
|
|
@ -284,14 +284,16 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
snapshot verifies its checksum and the current-state hash, replaces supported
|
||||||
project data atomically and records a new `restore` revision with a complete
|
project data atomically and records a new `restore` revision with a complete
|
||||||
inverse command. Restore can therefore be undone and redone after a restart.
|
inverse command. Restore can therefore be undone and redone after a restart.
|
||||||
Current snapshot schema version 7 additionally stores the project buildings
|
Current snapshot schema version 8 additionally stores the optional net room
|
||||||
and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6
|
area `rooms[].areaM2` (see `docs/building-structure.md`); versions 1 to 7 are
|
||||||
are upgraded with no buildings and `buildingId = null`. Version 6 stores the
|
upgraded with `areaM2 = null`. Version 7 stores the project buildings and
|
||||||
|
`floors[].buildingId`; versions 1 to 6 are upgraded with no buildings and
|
||||||
|
`buildingId = null`. Version 6 stores the
|
||||||
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
|
||||||
`remark` and the project cover fields `siteAddress`, `workStage`,
|
`remark` and the project cover fields `siteAddress`, `workStage`,
|
||||||
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
`projectLead` and `projectEditor`; all older supported versions are upgraded
|
||||||
with `null` for these fields.
|
with `null` for these fields.
|
||||||
Stored named and automatic snapshots of every supported version (1 to 7)
|
Stored named and automatic snapshots of every supported version (1 to 8)
|
||||||
remain restorable; they are upgraded while parsing. Version 5 stores
|
remain restorable; they are upgraded while parsing. Version 5 stores
|
||||||
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
`manualQuantity = quantity`; they already contain the complete confirmed
|
`manualQuantity = quantity`; they already contain the complete confirmed
|
||||||
|
|
@ -307,7 +309,7 @@ project graph.
|
||||||
Project-wide Revit CSV configuration uses
|
Project-wide Revit CSV configuration uses
|
||||||
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
`external-csv-configuration.update`. Its complete expected/target snapshot is
|
||||||
persisted through the shared project-command transaction, so create, update,
|
persisted through the shared project-command transaction, so create, update,
|
||||||
delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable
|
delete, Undo and Redo are revision-safe. Snapshot schema 5-8, restore and portable
|
||||||
project duplication preserve the configuration; duplication remaps its internal
|
project duplication preserve the configuration; duplication remaps its internal
|
||||||
id and project link.
|
id and project link.
|
||||||
The project page exposes the Revit CSV modal. It edits the complete
|
The project page exposes the Revit CSV modal. It edits the complete
|
||||||
|
|
@ -481,10 +483,15 @@ These fields are intended to be prefillable from Revit parameters later, but
|
||||||
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
only through the Phase 14 rules (preview, explicit confirmation, no silent
|
||||||
overwrite).
|
overwrite).
|
||||||
Floor and room setup uses persistent insert, update and delete commands with
|
Floor and room setup uses persistent insert, update and delete commands with
|
||||||
complete stable snapshots. Their inverses remove or restore only unchanged records and
|
complete stable snapshots. Room command schema version 2 stores the optional,
|
||||||
reject floors with assigned rooms/distribution boards or rooms referenced by
|
finite, non-negative net room area `areaM2`; persisted version-1 commands are
|
||||||
device rows. All write routes require `expectedRevision` and return the updated
|
upgraded with `areaM2 = null` and exact record comparison prevents them from
|
||||||
history state.
|
silently overwriting a newer area. Room areas are maintained manually, shown
|
||||||
|
with two decimals and summarized per floor and building with a separate missing
|
||||||
|
count. Revit import and W/m² use remain follow-up work. Command inverses remove
|
||||||
|
or restore only unchanged records and reject floors with
|
||||||
|
assigned rooms/distribution boards or rooms referenced by device rows. All
|
||||||
|
write routes require `expectedRevision` and return the updated history state.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ zugeordneten Etagen lassen sich nicht löschen.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts |
|
| `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 |
|
| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude |
|
||||||
|
| `rooms` | `area_m2` (nullable `REAL`) | optional gepflegte Netto-Raumfläche in m² |
|
||||||
|
|
||||||
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
|
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
|
||||||
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
|
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
|
||||||
|
|
@ -20,6 +21,11 @@ und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration
|
||||||
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
|
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
|
||||||
Datenbanksicherung anzulegen.
|
Datenbanksicherung anzulegen.
|
||||||
|
|
||||||
|
Migration `0011_glossy_norman_osborn` ergänzt ausschließlich das nullable Feld
|
||||||
|
`rooms.area_m2`. Bestehende Räume behalten damit eine unbekannte Fläche. Vor
|
||||||
|
dem Anwenden auf eine bestehende Datenbank ist ebenfalls eine separate
|
||||||
|
Datenbanksicherung erforderlich.
|
||||||
|
|
||||||
## Befehle, Undo/Redo
|
## Befehle, Undo/Redo
|
||||||
|
|
||||||
Alle Änderungen laufen über die bestehende Orts-Struktur
|
Alle Änderungen laufen über die bestehende Orts-Struktur
|
||||||
|
|
@ -41,17 +47,22 @@ persistentem Undo/Redo:
|
||||||
Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie
|
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
|
exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im
|
||||||
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
|
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
|
||||||
- Raumbefehle bleiben unverändert (Version 1).
|
- Raumbefehle `project-room.insert/.update/.delete` haben seit Schema-Version
|
||||||
|
**2** das Feld `areaM2`. Version-1-Befehle werden beim Einlesen mit
|
||||||
|
`areaM2 = null` hochgestuft. Vor einem alten Update oder Delete wird weiterhin
|
||||||
|
der vollständige erwartete Datensatz verglichen; eine inzwischen vorhandene
|
||||||
|
Fläche kann deshalb nicht unbemerkt gelöscht werden.
|
||||||
|
|
||||||
## Snapshots und Transfer
|
## Snapshots und Transfer
|
||||||
|
|
||||||
Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`.
|
Projekt-Snapshot-Schema **8** enthält zusätzlich `rooms[].areaM2`. Version 7
|
||||||
Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer
|
wird mit `areaM2 = null` hochgestuft; die älteren unterstützten Versionen 1–6
|
||||||
Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte
|
werden außerdem mit leerer Gebäudeliste und `buildingId = null` hochgestuft.
|
||||||
bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum
|
Gespeicherte Sicherungspunkte bleiben wiederherstellbar. Die Relationen werden
|
||||||
Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht
|
geprüft (Gebäude gehört zum Projekt, `buildingId` verweist auf ein Gebäude des
|
||||||
und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue
|
Snapshots). Restore und Projekttransfer erhalten die Raumfläche. Das portable
|
||||||
Gebäude-IDs und stellt die Etagenverweise um.
|
Transferformat bleibt Version 2, weil es den versionierten Projektsnapshot
|
||||||
|
bereits einbettet; beim Duplizieren werden nur IDs und Relationen umgesetzt.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
@ -63,6 +74,10 @@ Gebäude-IDs und stellt die Etagenverweise um.
|
||||||
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
|
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
|
||||||
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
|
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
|
||||||
das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert.
|
das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert.
|
||||||
|
- `POST/PUT /api/projects/:projectId/rooms[/:roomId]` akzeptieren zusätzlich
|
||||||
|
optional `areaM2` (`number | null`, endlich und ≥ 0). Beim Anlegen bedeutet
|
||||||
|
ein fehlendes Feld `null`; beim Bearbeiten bleibt die vorhandene Fläche
|
||||||
|
erhalten, wenn das Feld fehlt.
|
||||||
|
|
||||||
## Oberfläche
|
## Oberfläche
|
||||||
|
|
||||||
|
|
@ -74,10 +89,24 @@ nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Fi
|
||||||
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
|
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
|
||||||
Stromkreisliste.
|
Stromkreisliste.
|
||||||
|
|
||||||
|
Der Raumdialog pflegt die optionale **Fläche [m²]** mit deutscher
|
||||||
|
Dezimaltrennzeichen-Eingabe. Gemeint ist die Netto-Raumfläche, wie sie der
|
||||||
|
Revit-Raumparameter „Fläche“ liefert, nicht eine Flächenart nach DIN 277. Die
|
||||||
|
Fläche wird ohne fachliche Rundung gespeichert und mit zwei Nachkommastellen
|
||||||
|
angezeigt. Die Gebäudestruktur zeigt die Fläche je Raum sowie Summen je Etage,
|
||||||
|
je Gebäude, „Ohne Etage“ und „Ohne Gebäude“. Fehlende Flächen werden nicht als
|
||||||
|
Nullfläche ausgegeben, sondern separat gezählt, damit Teilsummen erkennbar
|
||||||
|
bleiben.
|
||||||
|
|
||||||
## Offen
|
## Offen
|
||||||
|
|
||||||
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
|
||||||
Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die
|
Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die
|
||||||
Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg.
|
Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg.
|
||||||
|
- Raumflächen werden derzeit ausschließlich manuell gepflegt. Ein späterer
|
||||||
|
Revit-Abgleich muss die Phase-14-Regeln mit externem Modellzustand, Vorschau
|
||||||
|
und ausdrücklicher Bestätigung einhalten.
|
||||||
|
- Eine W/m²-Auswertung oder Übernahme der Raumfläche in Flächenlasten ist nicht
|
||||||
|
Teil dieser Funktion; das ist Folgethema von Issue #20.
|
||||||
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
|
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
|
||||||
Gebäude werden hinten angehängt.
|
Gebäude werden hinten angehängt.
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
|
||||||
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
||||||
importierbar.
|
importierbar.
|
||||||
|
|
||||||
|
Das aktuelle Projekt-Snapshot-Schema 8 enthält außerdem die optionale
|
||||||
|
Netto-Raumfläche `rooms[].areaM2`. Snapshot-Versionen 1–7 werden dafür mit
|
||||||
|
`areaM2 = null` hochgestuft. Restore, Export und Import erhalten vorhandene
|
||||||
|
Flächen; eine eigene Transferformat-Version ist nicht nötig, weil Format 2 den
|
||||||
|
versionierten Projektsnapshot einbettet.
|
||||||
|
|
||||||
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||||
|
|
@ -297,19 +303,26 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||||
- rejects floors with assigned rooms or distribution boards
|
- rejects floors with assigned rooms or distribution boards
|
||||||
- `POST /projects/:projectId/rooms`
|
- `POST /projects/:projectId/rooms`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "expectedRevision": 14 }`
|
`{ "floorId": "floor_1", "roomNumber": "001", "roomName": "Technik", "areaM2": 18.75, "expectedRevision": 14 }`
|
||||||
- executes `project-room.insert` with a stable room id; `floorId` is optional
|
- executes `project-room.insert` command version 2 with a stable room id;
|
||||||
and must belong to the project when present
|
`floorId` and `areaM2` are optional. An omitted or explicit `null` area
|
||||||
|
creates a room without area; a value must be finite and non-negative.
|
||||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- `PUT /projects/:projectId/rooms/:roomId`
|
- `PUT /projects/:projectId/rooms/:roomId`
|
||||||
- body:
|
- body:
|
||||||
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "expectedRevision": 16 }`
|
`{ "floorId": null, "roomNumber": "101", "roomName": "Büro", "areaM2": null, "expectedRevision": 16 }`
|
||||||
- updates number, name and optional floor as one persistent command
|
- updates number, name, optional floor and area as one persistent version-2
|
||||||
|
command. If `areaM2` is omitted, the current area is retained; `null`
|
||||||
|
explicitly clears it.
|
||||||
- `DELETE /projects/:projectId/rooms/:roomId`
|
- `DELETE /projects/:projectId/rooms/:roomId`
|
||||||
- body: `{ "expectedRevision": 17 }`
|
- body: `{ "expectedRevision": 17 }`
|
||||||
- rejects rooms referenced by device rows
|
- rejects rooms referenced by device rows
|
||||||
- Persistent Undo/Redo restores exact previous and target records without
|
- Persistent Undo/Redo restores exact previous and target records without
|
||||||
silently clearing foreign keys.
|
silently clearing foreign keys.
|
||||||
|
- Stored version-1 room commands remain executable and are upgraded with
|
||||||
|
`areaM2 = null`. Exact expected-record checks reject an old update or delete
|
||||||
|
if a newer command has added an area, so legacy history cannot silently
|
||||||
|
erase it.
|
||||||
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|
|
||||||
|
|
@ -464,8 +464,10 @@ Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
||||||
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
|
||||||
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
|
||||||
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext.
|
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext.
|
||||||
Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung
|
Snapshot-Schema 8 enthält zusätzlich die optionale Netto-Raumfläche
|
||||||
der Etagen; ältere Versionen werden ohne Gebäude hochgestuft.
|
`rooms[].areaM2`; Versionen 1–7 werden dafür mit `null` hochgestuft.
|
||||||
|
Snapshot-Schema 7 enthält 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
|
Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
|
||||||
Projekts; ältere Versionen werden mit `null`
|
Projekts; ältere Versionen werden mit `null`
|
||||||
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||||
|
|
@ -483,7 +485,12 @@ Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft;
|
||||||
dieser Baseline werden nicht eingelesen.
|
dieser Baseline werden nicht eingelesen.
|
||||||
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
|
||||||
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
|
||||||
exakten Vorher-/Nachher-Snapshots. Ein Geschoss wird nur
|
exakten Vorher-/Nachher-Snapshots. Raumbefehle verwenden Version 2 und führen
|
||||||
|
die nullable, nicht negative Netto-Raumfläche `areaM2`; Version-1-Befehle
|
||||||
|
werden mit `null` hochgestuft und bleiben durch den vollständigen
|
||||||
|
Snapshotvergleich konfliktfest. Der Raumdialog pflegt die Fläche manuell mit
|
||||||
|
deutscher Dezimaleingabe, die Gebäudestruktur zeigt zwei Nachkommastellen sowie
|
||||||
|
Summen und die Anzahl fehlender Flächen je Etage und Gebäude. Ein Geschoss wird nur
|
||||||
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
||||||
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
|
Raum wird nur entfernt, solange keine CircuitDeviceRow auf ihn verweist. Alle
|
||||||
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
|
Schreibendpunkte verlangen `expectedRevision` und liefern den aktualisierten
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,17 @@ import {
|
||||||
filterRooms,
|
filterRooms,
|
||||||
groupFloorsByBuilding,
|
groupFloorsByBuilding,
|
||||||
hasFloorsWithoutBuilding,
|
hasFloorsWithoutBuilding,
|
||||||
|
hasRoomsWithoutFloor,
|
||||||
normalizeBuildingSelection,
|
normalizeBuildingSelection,
|
||||||
normalizeFloorSelection,
|
normalizeFloorSelection,
|
||||||
showsRoomsWithoutFloorEntry,
|
showsRoomsWithoutFloorEntry,
|
||||||
|
summarizeRoomAreas,
|
||||||
|
type RoomAreaSummary,
|
||||||
type StructureSelection,
|
type StructureSelection,
|
||||||
} from "../../../../frontend/utils/building-structure";
|
} from "../../../../frontend/utils/building-structure";
|
||||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||||
|
import { formatRoomArea } from "../../../../frontend/utils/room-area";
|
||||||
|
|
||||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||||
|
|
@ -68,6 +72,23 @@ function formatCount(count: number, singular: string, plural: string) {
|
||||||
return `${count} ${count === 1 ? singular : plural}`;
|
return `${count} ${count === 1 ? singular : plural}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAreaSummary(summary: RoomAreaSummary) {
|
||||||
|
return `${formatRoomArea(summary.areaM2)} m² · ${formatCount(
|
||||||
|
summary.missingAreaCount,
|
||||||
|
"Raum ohne Fläche",
|
||||||
|
"Räume ohne Fläche"
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStructureMeta(
|
||||||
|
count: number,
|
||||||
|
singular: string,
|
||||||
|
plural: string,
|
||||||
|
summary: RoomAreaSummary
|
||||||
|
) {
|
||||||
|
return `${formatCount(count, singular, plural)} · ${formatAreaSummary(summary)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
function compareRooms(left: RoomDto, right: RoomDto) {
|
||||||
return (
|
return (
|
||||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||||
|
|
@ -307,7 +328,25 @@ export default function BuildingStructurePage() {
|
||||||
() => countRoomsByFloor(rooms, floors),
|
() => countRoomsByFloor(rooms, floors),
|
||||||
[floors, rooms]
|
[floors, rooms]
|
||||||
);
|
);
|
||||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
const areaSummaries = useMemo(
|
||||||
|
() => summarizeRoomAreas(rooms, floors, buildings),
|
||||||
|
[buildings, floors, rooms]
|
||||||
|
);
|
||||||
|
const emptyAreaSummary: RoomAreaSummary = {
|
||||||
|
areaM2: 0,
|
||||||
|
roomCount: 0,
|
||||||
|
missingAreaCount: 0,
|
||||||
|
};
|
||||||
|
const selectedBuildingAreaSummary =
|
||||||
|
effectiveBuildingSelection === ALL_SELECTION
|
||||||
|
? areaSummaries.total
|
||||||
|
: areaSummaries.byBuilding.get(
|
||||||
|
effectiveBuildingSelection === UNASSIGNED_SELECTION
|
||||||
|
? null
|
||||||
|
: effectiveBuildingSelection
|
||||||
|
) ?? emptyAreaSummary;
|
||||||
|
const showUnassignedBuilding =
|
||||||
|
hasFloorsWithoutBuilding(floors, buildings) || hasRoomsWithoutFloor(rooms, floors);
|
||||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
||||||
rooms,
|
rooms,
|
||||||
floors,
|
floors,
|
||||||
|
|
@ -435,7 +474,8 @@ export default function BuildingStructurePage() {
|
||||||
editingRoom &&
|
editingRoom &&
|
||||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||||
editingRoom.roomName === input.roomName.trim() &&
|
editingRoom.roomName === input.roomName.trim() &&
|
||||||
editingRoom.floorId === (input.floorId || null)
|
editingRoom.floorId === (input.floorId || null) &&
|
||||||
|
editingRoom.areaM2 === input.areaM2
|
||||||
) {
|
) {
|
||||||
setDialog(null);
|
setDialog(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -536,7 +576,12 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||||
label="Alle"
|
label="Alle"
|
||||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
meta={formatStructureMeta(
|
||||||
|
floors.length,
|
||||||
|
"Etage",
|
||||||
|
"Etagen",
|
||||||
|
areaSummaries.total
|
||||||
|
)}
|
||||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||||
/>
|
/>
|
||||||
{buildings.map((building) => (
|
{buildings.map((building) => (
|
||||||
|
|
@ -553,10 +598,11 @@ export default function BuildingStructurePage() {
|
||||||
isSelected={effectiveBuildingSelection === building.id}
|
isSelected={effectiveBuildingSelection === building.id}
|
||||||
key={building.id}
|
key={building.id}
|
||||||
label={building.name}
|
label={building.name}
|
||||||
meta={formatCount(
|
meta={formatStructureMeta(
|
||||||
floorCountByBuilding.get(building.id) ?? 0,
|
floorCountByBuilding.get(building.id) ?? 0,
|
||||||
"Etage",
|
"Etage",
|
||||||
"Etagen"
|
"Etagen",
|
||||||
|
areaSummaries.byBuilding.get(building.id) ?? emptyAreaSummary
|
||||||
)}
|
)}
|
||||||
onSelect={() => selectBuilding(building.id)}
|
onSelect={() => selectBuilding(building.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -565,10 +611,11 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||||
meta={formatCount(
|
meta={formatStructureMeta(
|
||||||
floorCountByBuilding.get(null) ?? 0,
|
floorCountByBuilding.get(null) ?? 0,
|
||||||
"Etage",
|
"Etage",
|
||||||
"Etagen"
|
"Etagen",
|
||||||
|
areaSummaries.byBuilding.get(null) ?? emptyAreaSummary
|
||||||
)}
|
)}
|
||||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -591,7 +638,12 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||||
label="Alle"
|
label="Alle"
|
||||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
meta={formatStructureMeta(
|
||||||
|
roomsInBuildingSelection,
|
||||||
|
"Raum",
|
||||||
|
"Räume",
|
||||||
|
selectedBuildingAreaSummary
|
||||||
|
)}
|
||||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||||
/>
|
/>
|
||||||
{visibleFloorGroups.map((group) => (
|
{visibleFloorGroups.map((group) => (
|
||||||
|
|
@ -613,10 +665,11 @@ export default function BuildingStructurePage() {
|
||||||
isSelected={effectiveFloorSelection === floor.id}
|
isSelected={effectiveFloorSelection === floor.id}
|
||||||
key={floor.id}
|
key={floor.id}
|
||||||
label={floor.name}
|
label={floor.name}
|
||||||
meta={formatCount(
|
meta={formatStructureMeta(
|
||||||
roomCountByFloor.get(floor.id) ?? 0,
|
roomCountByFloor.get(floor.id) ?? 0,
|
||||||
"Raum",
|
"Raum",
|
||||||
"Räume"
|
"Räume",
|
||||||
|
areaSummaries.byFloor.get(floor.id) ?? emptyAreaSummary
|
||||||
)}
|
)}
|
||||||
onSelect={() => setFloorSelection(floor.id)}
|
onSelect={() => setFloorSelection(floor.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -627,7 +680,12 @@ export default function BuildingStructurePage() {
|
||||||
<StructureEntry
|
<StructureEntry
|
||||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
meta={formatStructureMeta(
|
||||||
|
roomCountByFloor.get(null) ?? 0,
|
||||||
|
"Raum",
|
||||||
|
"Räume",
|
||||||
|
areaSummaries.byFloor.get(null) ?? emptyAreaSummary
|
||||||
|
)}
|
||||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -671,6 +729,11 @@ export default function BuildingStructurePage() {
|
||||||
{room.roomName}
|
{room.roomName}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
meta={
|
||||||
|
room.areaM2 === null
|
||||||
|
? "Fläche fehlt"
|
||||||
|
: `${formatRoomArea(room.areaM2)} m²`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{!visibleRooms.length && !isLoading ? (
|
{!visibleRooms.length && !isLoading ? (
|
||||||
|
|
|
||||||
1
src/db/migrations/0011_glossy_norman_osborn.sql
Normal file
1
src/db/migrations/0011_glossy_norman_osborn.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE `rooms` ADD `area_m2` real;
|
||||||
2931
src/db/migrations/meta/0011_snapshot.json
Normal file
2931
src/db/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -78,6 +78,13 @@
|
||||||
"when": 1790438092269,
|
"when": 1790438092269,
|
||||||
"tag": "0010_estimate_balances",
|
"tag": "0010_estimate_balances",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790531351492,
|
||||||
|
"tag": "0011_glossy_norman_osborn",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
projectRoomInsertCommandType,
|
projectRoomInsertCommandType,
|
||||||
projectRoomUpdateCommandType,
|
projectRoomUpdateCommandType,
|
||||||
upgradeProjectFloorCommand,
|
upgradeProjectFloorCommand,
|
||||||
|
upgradeProjectRoomCommand,
|
||||||
type ProjectBuildingSnapshot,
|
type ProjectBuildingSnapshot,
|
||||||
type ProjectFloorSnapshot,
|
type ProjectFloorSnapshot,
|
||||||
type ProjectLocationStructureProjectCommand,
|
type ProjectLocationStructureProjectCommand,
|
||||||
|
|
@ -106,23 +107,29 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
const current = upgradeProjectFloorCommand(command);
|
const current = upgradeProjectFloorCommand(command);
|
||||||
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
||||||
}
|
}
|
||||||
case projectRoomInsertCommandType:
|
case projectRoomInsertCommandType: {
|
||||||
assertProjectRoomInsertProjectCommand(command);
|
assertProjectRoomInsertProjectCommand(command);
|
||||||
|
const current = upgradeProjectRoomCommand(command);
|
||||||
return this.insertRoom(
|
return this.insertRoom(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.room
|
current.payload.room
|
||||||
);
|
);
|
||||||
case projectRoomDeleteCommandType:
|
}
|
||||||
|
case projectRoomDeleteCommandType: {
|
||||||
assertProjectRoomDeleteProjectCommand(command);
|
assertProjectRoomDeleteProjectCommand(command);
|
||||||
|
const current = upgradeProjectRoomCommand(command);
|
||||||
return this.deleteRoom(
|
return this.deleteRoom(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.room
|
current.payload.room
|
||||||
);
|
);
|
||||||
case projectRoomUpdateCommandType:
|
}
|
||||||
|
case projectRoomUpdateCommandType: {
|
||||||
assertProjectRoomUpdateProjectCommand(command);
|
assertProjectRoomUpdateProjectCommand(command);
|
||||||
return this.updateRoom(database, projectId, command.payload.expected, command.payload.target);
|
const current = upgradeProjectRoomCommand(command);
|
||||||
|
return this.updateRoom(database, projectId, current.payload.expected, current.payload.target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,6 +400,7 @@ export class ProjectLocationStructureProjectCommandRepository
|
||||||
floorId: target.floorId,
|
floorId: target.floorId,
|
||||||
roomNumber: target.roomNumber,
|
roomNumber: target.roomNumber,
|
||||||
roomName: target.roomName,
|
roomName: target.roomName,
|
||||||
|
areaM2: target.areaM2,
|
||||||
})
|
})
|
||||||
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
|
.where(and(eq(rooms.id, expected.id), eq(rooms.projectId, projectId)))
|
||||||
.run();
|
.run();
|
||||||
|
|
@ -474,6 +482,15 @@ function toCurrentCommandVersion(
|
||||||
case projectFloorUpdateCommandType:
|
case projectFloorUpdateCommandType:
|
||||||
assertProjectFloorUpdateProjectCommand(command);
|
assertProjectFloorUpdateProjectCommand(command);
|
||||||
return upgradeProjectFloorCommand(command);
|
return upgradeProjectFloorCommand(command);
|
||||||
|
case projectRoomInsertCommandType:
|
||||||
|
assertProjectRoomInsertProjectCommand(command);
|
||||||
|
return upgradeProjectRoomCommand(command);
|
||||||
|
case projectRoomDeleteCommandType:
|
||||||
|
assertProjectRoomDeleteProjectCommand(command);
|
||||||
|
return upgradeProjectRoomCommand(command);
|
||||||
|
case projectRoomUpdateCommandType:
|
||||||
|
assertProjectRoomUpdateProjectCommand(command);
|
||||||
|
return upgradeProjectRoomCommand(command);
|
||||||
default:
|
default:
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
import { floors } from "./floors.js";
|
import { floors } from "./floors.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
|
@ -10,4 +10,6 @@ export const rooms = sqliteTable("rooms", {
|
||||||
floorId: text("floor_id").references(() => floors.id, { onDelete: "set null" }),
|
floorId: text("floor_id").references(() => floors.id, { onDelete: "set null" }),
|
||||||
roomNumber: text("room_number").notNull(),
|
roomNumber: text("room_number").notNull(),
|
||||||
roomName: text("room_name").notNull(),
|
roomName: text("room_name").notNull(),
|
||||||
|
/** Optional net room area from the Revit room parameter "Fläche". */
|
||||||
|
areaM2: real("area_m2"),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const projectFloorUpdateCommandType = "project-floor.update" as const;
|
||||||
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
||||||
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
||||||
export const projectRoomUpdateCommandType = "project-room.update" as const;
|
export const projectRoomUpdateCommandType = "project-room.update" as const;
|
||||||
/** Room commands, building commands and legacy floor commands. */
|
/** Building commands and legacy floor/room commands. */
|
||||||
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||||
/**
|
/**
|
||||||
* Floor commands carry `buildingId` since version 2. Version 1 floor
|
* Floor commands carry `buildingId` since version 2. Version 1 floor
|
||||||
|
|
@ -19,6 +19,9 @@ export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||||
*/
|
*/
|
||||||
export const projectFloorCommandSchemaVersion = 2 as const;
|
export const projectFloorCommandSchemaVersion = 2 as const;
|
||||||
const legacyProjectFloorCommandSchemaVersion = 1 as const;
|
const legacyProjectFloorCommandSchemaVersion = 1 as const;
|
||||||
|
/** Room commands carry `areaM2` since version 2. */
|
||||||
|
export const projectRoomCommandSchemaVersion = 2 as const;
|
||||||
|
const legacyProjectRoomCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
export interface ProjectBuildingSnapshot {
|
export interface ProjectBuildingSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -43,8 +46,11 @@ export interface ProjectRoomSnapshot {
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
|
areaM2: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LegacyProjectRoomSnapshot = Omit<ProjectRoomSnapshot, "areaM2">;
|
||||||
|
|
||||||
interface ProjectBuildingStructureCommandPayload {
|
interface ProjectBuildingStructureCommandPayload {
|
||||||
building: ProjectBuildingSnapshot;
|
building: ProjectBuildingSnapshot;
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +67,10 @@ interface ProjectRoomStructureCommandPayload {
|
||||||
room: ProjectRoomSnapshot;
|
room: ProjectRoomSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LegacyProjectRoomStructureCommandPayload {
|
||||||
|
room: LegacyProjectRoomSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectBuildingUpdateCommandPayload {
|
interface ProjectBuildingUpdateCommandPayload {
|
||||||
expected: ProjectBuildingSnapshot;
|
expected: ProjectBuildingSnapshot;
|
||||||
target: ProjectBuildingSnapshot;
|
target: ProjectBuildingSnapshot;
|
||||||
|
|
@ -81,6 +91,11 @@ interface ProjectRoomUpdateCommandPayload {
|
||||||
target: ProjectRoomSnapshot;
|
target: ProjectRoomSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LegacyProjectRoomUpdateCommandPayload {
|
||||||
|
expected: LegacyProjectRoomSnapshot;
|
||||||
|
target: LegacyProjectRoomSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectBuildingInsertProjectCommand
|
export interface ProjectBuildingInsertProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||||
|
|
@ -137,19 +152,37 @@ export interface LegacyProjectFloorUpdateProjectCommand
|
||||||
|
|
||||||
export interface ProjectRoomInsertProjectCommand
|
export interface ProjectRoomInsertProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
schemaVersion: typeof projectRoomCommandSchemaVersion;
|
||||||
type: typeof projectRoomInsertCommandType;
|
type: typeof projectRoomInsertCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectRoomDeleteProjectCommand
|
export interface ProjectRoomDeleteProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
extends SerializedProjectCommand<ProjectRoomStructureCommandPayload> {
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
schemaVersion: typeof projectRoomCommandSchemaVersion;
|
||||||
type: typeof projectRoomDeleteCommandType;
|
type: typeof projectRoomDeleteCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectRoomUpdateProjectCommand
|
export interface ProjectRoomUpdateProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
|
extends SerializedProjectCommand<ProjectRoomUpdateCommandPayload> {
|
||||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
schemaVersion: typeof projectRoomCommandSchemaVersion;
|
||||||
|
type: typeof projectRoomUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyProjectRoomInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<LegacyProjectRoomStructureCommandPayload> {
|
||||||
|
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
|
||||||
|
type: typeof projectRoomInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyProjectRoomDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<LegacyProjectRoomStructureCommandPayload> {
|
||||||
|
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
|
||||||
|
type: typeof projectRoomDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyProjectRoomUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<LegacyProjectRoomUpdateCommandPayload> {
|
||||||
|
schemaVersion: typeof legacyProjectRoomCommandSchemaVersion;
|
||||||
type: typeof projectRoomUpdateCommandType;
|
type: typeof projectRoomUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +198,10 @@ export type ProjectLocationStructureProjectCommand =
|
||||||
| LegacyProjectFloorUpdateProjectCommand
|
| LegacyProjectFloorUpdateProjectCommand
|
||||||
| ProjectRoomInsertProjectCommand
|
| ProjectRoomInsertProjectCommand
|
||||||
| ProjectRoomDeleteProjectCommand
|
| ProjectRoomDeleteProjectCommand
|
||||||
| ProjectRoomUpdateProjectCommand;
|
| ProjectRoomUpdateProjectCommand
|
||||||
|
| LegacyProjectRoomInsertProjectCommand
|
||||||
|
| LegacyProjectRoomDeleteProjectCommand
|
||||||
|
| LegacyProjectRoomUpdateProjectCommand;
|
||||||
|
|
||||||
export function createProjectBuildingSnapshot(
|
export function createProjectBuildingSnapshot(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -205,6 +241,7 @@ export function createProjectRoomSnapshot(
|
||||||
floorId?: string;
|
floorId?: string;
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
|
areaM2?: number | null;
|
||||||
}
|
}
|
||||||
): ProjectRoomSnapshot {
|
): ProjectRoomSnapshot {
|
||||||
const room: ProjectRoomSnapshot = {
|
const room: ProjectRoomSnapshot = {
|
||||||
|
|
@ -213,6 +250,7 @@ export function createProjectRoomSnapshot(
|
||||||
floorId: input.floorId?.trim() || null,
|
floorId: input.floorId?.trim() || null,
|
||||||
roomNumber: input.roomNumber.trim(),
|
roomNumber: input.roomNumber.trim(),
|
||||||
roomName: input.roomName.trim(),
|
roomName: input.roomName.trim(),
|
||||||
|
areaM2: input.areaM2 ?? null,
|
||||||
};
|
};
|
||||||
assertProjectRoomSnapshot(room);
|
assertProjectRoomSnapshot(room);
|
||||||
return room;
|
return room;
|
||||||
|
|
@ -279,7 +317,7 @@ export function createProjectRoomInsertProjectCommand(
|
||||||
): ProjectRoomInsertProjectCommand {
|
): ProjectRoomInsertProjectCommand {
|
||||||
assertProjectRoomSnapshot(room);
|
assertProjectRoomSnapshot(room);
|
||||||
return {
|
return {
|
||||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
schemaVersion: projectRoomCommandSchemaVersion,
|
||||||
type: projectRoomInsertCommandType,
|
type: projectRoomInsertCommandType,
|
||||||
payload: { room },
|
payload: { room },
|
||||||
};
|
};
|
||||||
|
|
@ -290,7 +328,7 @@ export function createProjectRoomDeleteProjectCommand(
|
||||||
): ProjectRoomDeleteProjectCommand {
|
): ProjectRoomDeleteProjectCommand {
|
||||||
assertProjectRoomSnapshot(room);
|
assertProjectRoomSnapshot(room);
|
||||||
return {
|
return {
|
||||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
schemaVersion: projectRoomCommandSchemaVersion,
|
||||||
type: projectRoomDeleteCommandType,
|
type: projectRoomDeleteCommandType,
|
||||||
payload: { room },
|
payload: { room },
|
||||||
};
|
};
|
||||||
|
|
@ -314,7 +352,7 @@ export function createProjectRoomUpdateProjectCommand(
|
||||||
): ProjectRoomUpdateProjectCommand {
|
): ProjectRoomUpdateProjectCommand {
|
||||||
assertMatchingRoomUpdate(expected, target);
|
assertMatchingRoomUpdate(expected, target);
|
||||||
return {
|
return {
|
||||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
schemaVersion: projectRoomCommandSchemaVersion,
|
||||||
type: projectRoomUpdateCommandType,
|
type: projectRoomUpdateCommandType,
|
||||||
payload: { expected, target },
|
payload: { expected, target },
|
||||||
};
|
};
|
||||||
|
|
@ -376,7 +414,9 @@ export function assertProjectFloorDeleteProjectCommand(
|
||||||
|
|
||||||
export function assertProjectRoomInsertProjectCommand(
|
export function assertProjectRoomInsertProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is ProjectRoomInsertProjectCommand {
|
): asserts command is
|
||||||
|
| ProjectRoomInsertProjectCommand
|
||||||
|
| LegacyProjectRoomInsertProjectCommand {
|
||||||
assertProjectRoomStructureProjectCommand(
|
assertProjectRoomStructureProjectCommand(
|
||||||
command,
|
command,
|
||||||
projectRoomInsertCommandType
|
projectRoomInsertCommandType
|
||||||
|
|
@ -385,7 +425,9 @@ export function assertProjectRoomInsertProjectCommand(
|
||||||
|
|
||||||
export function assertProjectRoomDeleteProjectCommand(
|
export function assertProjectRoomDeleteProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is ProjectRoomDeleteProjectCommand {
|
): asserts command is
|
||||||
|
| ProjectRoomDeleteProjectCommand
|
||||||
|
| LegacyProjectRoomDeleteProjectCommand {
|
||||||
assertProjectRoomStructureProjectCommand(
|
assertProjectRoomStructureProjectCommand(
|
||||||
command,
|
command,
|
||||||
projectRoomDeleteCommandType
|
projectRoomDeleteCommandType
|
||||||
|
|
@ -419,15 +461,26 @@ export function assertProjectFloorUpdateProjectCommand(
|
||||||
|
|
||||||
export function assertProjectRoomUpdateProjectCommand(
|
export function assertProjectRoomUpdateProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is ProjectRoomUpdateProjectCommand {
|
): asserts command is
|
||||||
|
| ProjectRoomUpdateProjectCommand
|
||||||
|
| LegacyProjectRoomUpdateProjectCommand {
|
||||||
if (
|
if (
|
||||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
|
||||||
command.type !== projectRoomUpdateCommandType ||
|
command.type !== projectRoomUpdateCommandType ||
|
||||||
!isPlainObject(command.payload) ||
|
!isPlainObject(command.payload) ||
|
||||||
Object.keys(command.payload).length !== 2
|
Object.keys(command.payload).length !== 2
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project-room update command.");
|
throw new Error("Unsupported project-room update command.");
|
||||||
}
|
}
|
||||||
|
if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) {
|
||||||
|
assertMatchingRoomUpdate(
|
||||||
|
upgradeLegacyRoomSnapshot(command.payload.expected),
|
||||||
|
upgradeLegacyRoomSnapshot(command.payload.target)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.schemaVersion !== projectRoomCommandSchemaVersion) {
|
||||||
|
throw new Error("Unsupported project-room update command.");
|
||||||
|
}
|
||||||
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
|
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -480,6 +533,51 @@ export function upgradeProjectFloorCommand(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upgrades persisted version-1 room commands with an empty area. */
|
||||||
|
export function upgradeProjectRoomCommand(
|
||||||
|
command:
|
||||||
|
| ProjectRoomInsertProjectCommand
|
||||||
|
| LegacyProjectRoomInsertProjectCommand
|
||||||
|
): ProjectRoomInsertProjectCommand;
|
||||||
|
export function upgradeProjectRoomCommand(
|
||||||
|
command:
|
||||||
|
| ProjectRoomDeleteProjectCommand
|
||||||
|
| LegacyProjectRoomDeleteProjectCommand
|
||||||
|
): ProjectRoomDeleteProjectCommand;
|
||||||
|
export function upgradeProjectRoomCommand(
|
||||||
|
command:
|
||||||
|
| ProjectRoomUpdateProjectCommand
|
||||||
|
| LegacyProjectRoomUpdateProjectCommand
|
||||||
|
): ProjectRoomUpdateProjectCommand;
|
||||||
|
export function upgradeProjectRoomCommand(
|
||||||
|
command:
|
||||||
|
| ProjectRoomInsertProjectCommand
|
||||||
|
| ProjectRoomDeleteProjectCommand
|
||||||
|
| ProjectRoomUpdateProjectCommand
|
||||||
|
| LegacyProjectRoomInsertProjectCommand
|
||||||
|
| LegacyProjectRoomDeleteProjectCommand
|
||||||
|
| LegacyProjectRoomUpdateProjectCommand
|
||||||
|
) {
|
||||||
|
if (command.schemaVersion === projectRoomCommandSchemaVersion) {
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
switch (command.type) {
|
||||||
|
case projectRoomInsertCommandType:
|
||||||
|
return createProjectRoomInsertProjectCommand(
|
||||||
|
upgradeLegacyRoomSnapshot(command.payload.room)
|
||||||
|
);
|
||||||
|
case projectRoomDeleteCommandType:
|
||||||
|
return createProjectRoomDeleteProjectCommand(
|
||||||
|
upgradeLegacyRoomSnapshot(command.payload.room)
|
||||||
|
);
|
||||||
|
case projectRoomUpdateCommandType:
|
||||||
|
return createProjectRoomUpdateProjectCommand(
|
||||||
|
upgradeLegacyRoomSnapshot(command.payload.expected),
|
||||||
|
upgradeLegacyRoomSnapshot(command.payload.target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
|
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
|
||||||
assertProjectBuildingSnapshot(expected);
|
assertProjectBuildingSnapshot(expected);
|
||||||
assertProjectBuildingSnapshot(target);
|
assertProjectBuildingSnapshot(target);
|
||||||
|
|
@ -568,6 +666,29 @@ function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
|
||||||
export function assertProjectRoomSnapshot(
|
export function assertProjectRoomSnapshot(
|
||||||
room: unknown
|
room: unknown
|
||||||
): asserts room is ProjectRoomSnapshot {
|
): asserts room is ProjectRoomSnapshot {
|
||||||
|
if (!isPlainObject(room) || Object.keys(room).length !== 6) {
|
||||||
|
throw new Error("Project-room snapshot is invalid.");
|
||||||
|
}
|
||||||
|
assertNormalizedNonEmptyString(room.id, "room.id");
|
||||||
|
assertNormalizedNonEmptyString(room.projectId, "room.projectId");
|
||||||
|
if (room.floorId !== null) {
|
||||||
|
assertNormalizedNonEmptyString(room.floorId, "room.floorId");
|
||||||
|
}
|
||||||
|
assertNormalizedNonEmptyString(room.roomNumber, "room.roomNumber");
|
||||||
|
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||||
|
if (
|
||||||
|
room.areaM2 !== null &&
|
||||||
|
(typeof room.areaM2 !== "number" ||
|
||||||
|
!Number.isFinite(room.areaM2) ||
|
||||||
|
room.areaM2 < 0)
|
||||||
|
) {
|
||||||
|
throw new Error("room.areaM2 must be null or a non-negative finite number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLegacyProjectRoomSnapshot(
|
||||||
|
room: unknown
|
||||||
|
): asserts room is LegacyProjectRoomSnapshot {
|
||||||
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
|
if (!isPlainObject(room) || Object.keys(room).length !== 5) {
|
||||||
throw new Error("Project-room snapshot is invalid.");
|
throw new Error("Project-room snapshot is invalid.");
|
||||||
}
|
}
|
||||||
|
|
@ -580,6 +701,11 @@ export function assertProjectRoomSnapshot(
|
||||||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upgradeLegacyRoomSnapshot(room: unknown): ProjectRoomSnapshot {
|
||||||
|
assertLegacyProjectRoomSnapshot(room);
|
||||||
|
return { ...room, areaM2: null };
|
||||||
|
}
|
||||||
|
|
||||||
function assertProjectBuildingStructureProjectCommand(
|
function assertProjectBuildingStructureProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>,
|
command: SerializedProjectCommand<unknown>,
|
||||||
expectedType:
|
expectedType:
|
||||||
|
|
@ -627,13 +753,19 @@ function assertProjectRoomStructureProjectCommand(
|
||||||
| typeof projectRoomDeleteCommandType
|
| typeof projectRoomDeleteCommandType
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
|
||||||
command.type !== expectedType ||
|
command.type !== expectedType ||
|
||||||
!isPlainObject(command.payload) ||
|
!isPlainObject(command.payload) ||
|
||||||
Object.keys(command.payload).length !== 1
|
Object.keys(command.payload).length !== 1
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project-room structure command.");
|
throw new Error("Unsupported project-room structure command.");
|
||||||
}
|
}
|
||||||
|
if (command.schemaVersion === legacyProjectRoomCommandSchemaVersion) {
|
||||||
|
assertLegacyProjectRoomSnapshot(command.payload.room);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.schemaVersion !== projectRoomCommandSchemaVersion) {
|
||||||
|
throw new Error("Unsupported project-room structure command.");
|
||||||
|
}
|
||||||
assertProjectRoomSnapshot(command.payload.room);
|
assertProjectRoomSnapshot(command.payload.room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ import {
|
||||||
type ExternalModelStateSnapshot,
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
export const projectStateSnapshotSchemaVersion = 8 as const;
|
||||||
|
const buildingProjectStateSnapshotSchemaVersion = 7 as const;
|
||||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
||||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||||
|
|
@ -260,7 +261,7 @@ const floorSchema = legacyFloorSchema
|
||||||
.extend({ buildingId: idSchema.nullable() })
|
.extend({ buildingId: idSchema.nullable() })
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const roomSchema = z
|
const legacyRoomSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
|
|
@ -270,6 +271,10 @@ const roomSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const roomSchema = legacyRoomSchema
|
||||||
|
.extend({ areaM2: finiteNumberSchema.nonnegative().nullable() })
|
||||||
|
.strict();
|
||||||
|
|
||||||
const distributionBoardComponentSchema = z
|
const distributionBoardComponentSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
|
|
@ -504,14 +509,27 @@ export function parseExternalModelStateSnapshot(
|
||||||
return externalModelStateSchema.parse(value);
|
return externalModelStateSchema.parse(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Versions 1-6 have floors without a building and no building list.
|
// Versions 1-6 have floors without a building and no building list. Versions
|
||||||
|
// 1-7 have rooms without an area.
|
||||||
const previousProjectStateSnapshotContents = {
|
const previousProjectStateSnapshotContents = {
|
||||||
circuitLists: z.array(circuitListSchema),
|
circuitLists: z.array(circuitListSchema),
|
||||||
circuitSections: z.array(circuitSectionSchema),
|
circuitSections: z.array(circuitSectionSchema),
|
||||||
circuits: z.array(circuitSchema),
|
circuits: z.array(circuitSchema),
|
||||||
projectDevices: z.array(projectDeviceSchema),
|
projectDevices: z.array(projectDeviceSchema),
|
||||||
floors: z.array(legacyFloorSchema),
|
floors: z.array(legacyFloorSchema),
|
||||||
rooms: z.array(roomSchema),
|
rooms: z.array(legacyRoomSchema),
|
||||||
|
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||||
|
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||||
|
distributionBoardComponentProtectionDevices: z.array(
|
||||||
|
componentProtectionDeviceSchema
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildingProjectStateSnapshotContents = {
|
||||||
|
...previousProjectStateSnapshotContents,
|
||||||
|
buildings: z.array(buildingSchema),
|
||||||
|
floors: z.array(floorSchema),
|
||||||
|
rooms: z.array(legacyRoomSchema),
|
||||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||||
distributionBoardComponentProtectionDevices: z.array(
|
distributionBoardComponentProtectionDevices: z.array(
|
||||||
|
|
@ -520,15 +538,8 @@ const previousProjectStateSnapshotContents = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectStateSnapshotContents = {
|
const projectStateSnapshotContents = {
|
||||||
...previousProjectStateSnapshotContents,
|
...buildingProjectStateSnapshotContents,
|
||||||
buildings: z.array(buildingSchema),
|
|
||||||
floors: z.array(floorSchema),
|
|
||||||
rooms: z.array(roomSchema),
|
rooms: z.array(roomSchema),
|
||||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
|
||||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
|
||||||
distributionBoardComponentProtectionDevices: z.array(
|
|
||||||
componentProtectionDeviceSchema
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const projectStateSnapshotSchema = z
|
export const projectStateSnapshotSchema = z
|
||||||
|
|
@ -542,6 +553,17 @@ export const projectStateSnapshotSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const buildingProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(buildingProjectStateSnapshotSchemaVersion),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
|
externalModel: externalModelStateSchema,
|
||||||
|
...buildingProjectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const coverProjectStateSnapshotSchema = z
|
const coverProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
||||||
|
|
@ -634,6 +656,20 @@ export function parseProjectStateSnapshot(
|
||||||
|
|
||||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
const cover = upgradeToCoverProjectStateSnapshot(value);
|
const cover = upgradeToCoverProjectStateSnapshot(value);
|
||||||
|
if (
|
||||||
|
cover !== null &&
|
||||||
|
typeof cover === "object" &&
|
||||||
|
!Array.isArray(cover) &&
|
||||||
|
(cover as { schemaVersion?: unknown }).schemaVersion ===
|
||||||
|
buildingProjectStateSnapshotSchemaVersion
|
||||||
|
) {
|
||||||
|
const previous = buildingProjectStateSnapshotSchema.parse(cover);
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
rooms: previous.rooms.map((room) => ({ ...room, areaM2: null })),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
cover === null ||
|
cover === null ||
|
||||||
typeof cover !== "object" ||
|
typeof cover !== "object" ||
|
||||||
|
|
@ -644,14 +680,15 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
return cover;
|
return cover;
|
||||||
}
|
}
|
||||||
// Every supported version before 7 lacks buildings; its floors had no
|
// Every supported version before 7 lacks buildings; its floors had no
|
||||||
// building.
|
// building. Build version 7 first so the room-area upgrade above remains a
|
||||||
|
// single exact transition.
|
||||||
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
||||||
return {
|
return upgradePreviousProjectStateSnapshot({
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: buildingProjectStateSnapshotSchemaVersion,
|
||||||
buildings: [],
|
buildings: [],
|
||||||
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
|
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ import React, { type FormEvent, useState } from "react";
|
||||||
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
||||||
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
||||||
import { FormModal } from "./form-modal";
|
import { FormModal } from "./form-modal";
|
||||||
|
import {
|
||||||
|
formatOptionalRoomAreaInput,
|
||||||
|
parseRoomAreaInput,
|
||||||
|
} from "../utils/room-area";
|
||||||
|
|
||||||
interface RoomModalProps {
|
interface RoomModalProps {
|
||||||
/** When given, the floor select groups floors by building. */
|
/** When given, the floor select groups floors by building. */
|
||||||
|
|
@ -36,14 +40,21 @@ export function RoomModal({
|
||||||
const [floorId, setFloorId] = useState(
|
const [floorId, setFloorId] = useState(
|
||||||
initialRoom?.floorId ?? initialValues?.floorId ?? ""
|
initialRoom?.floorId ?? initialValues?.floorId ?? ""
|
||||||
);
|
);
|
||||||
|
const [areaText, setAreaText] = useState(() =>
|
||||||
|
formatOptionalRoomAreaInput(
|
||||||
|
initialRoom ? initialRoom.areaM2 : initialValues?.areaM2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const parsedArea = parseRoomAreaInput(areaText);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!roomNumber.trim() || !roomName.trim()) return;
|
if (!roomNumber.trim() || !roomName.trim() || !parsedArea.ok) return;
|
||||||
await onSave({
|
await onSave({
|
||||||
floorId: floorId || undefined,
|
floorId: floorId || undefined,
|
||||||
roomNumber: roomNumber.trim(),
|
roomNumber: roomNumber.trim(),
|
||||||
roomName: roomName.trim(),
|
roomName: roomName.trim(),
|
||||||
|
areaM2: parsedArea.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +63,7 @@ export function RoomModal({
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitDisabled={!roomNumber.trim() || !roomName.trim()}
|
submitDisabled={!roomNumber.trim() || !roomName.trim() || !parsedArea.ok}
|
||||||
submitLabel={initialRoom ? "Änderungen speichern" : "Raum anlegen"}
|
submitLabel={initialRoom ? "Änderungen speichern" : "Raum anlegen"}
|
||||||
title={initialRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
|
title={initialRoom ? "Raum bearbeiten" : "Raum hinzufügen"}
|
||||||
>
|
>
|
||||||
|
|
@ -90,6 +101,27 @@ export function RoomModal({
|
||||||
<FloorSelectOptions buildings={buildings} floors={floors} />
|
<FloorSelectOptions buildings={buildings} floors={floors} />
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="room-area">
|
||||||
|
Fläche [m²]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
aria-describedby="room-area-help"
|
||||||
|
aria-invalid={!parsedArea.ok}
|
||||||
|
className={`form-control${parsedArea.ok ? "" : " is-invalid"}`}
|
||||||
|
id="room-area"
|
||||||
|
inputMode="decimal"
|
||||||
|
onChange={(event) => setAreaText(event.target.value)}
|
||||||
|
placeholder="z. B. 23,45"
|
||||||
|
type="text"
|
||||||
|
value={areaText}
|
||||||
|
/>
|
||||||
|
<div className={parsedArea.ok ? "form-text" : "invalid-feedback"} id="room-area-help">
|
||||||
|
{parsedArea.ok
|
||||||
|
? "Optionale Netto-Raumfläche; Eingabe mit Dezimalkomma."
|
||||||
|
: parsedArea.error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{initialRoom && onDelete ? (
|
{initialRoom && onDelete ? (
|
||||||
<div className="border-top mt-4 pt-3">
|
<div className="border-top mt-4 pt-3">
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,7 @@ export interface RoomDto {
|
||||||
floorId: string | null;
|
floorId: string | null;
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
|
areaM2: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalDeviceDto {
|
export interface GlobalDeviceDto {
|
||||||
|
|
@ -315,6 +316,7 @@ export interface CreateRoomInput {
|
||||||
floorId?: string;
|
floorId?: string;
|
||||||
roomNumber: string;
|
roomNumber: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
|
areaM2: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateGlobalDeviceInput {
|
export interface CreateGlobalDeviceInput {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,19 @@ type FloorLike = Pick<FloorDto, "id" | "name"> & {
|
||||||
buildingId?: string | null;
|
buildingId?: string | null;
|
||||||
};
|
};
|
||||||
type RoomLike = Pick<RoomDto, "floorId">;
|
type RoomLike = Pick<RoomDto, "floorId">;
|
||||||
|
type RoomAreaLike = Pick<RoomDto, "floorId" | "areaM2">;
|
||||||
|
|
||||||
|
export interface RoomAreaSummary {
|
||||||
|
areaM2: number;
|
||||||
|
roomCount: number;
|
||||||
|
missingAreaCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StructureAreaSummaries {
|
||||||
|
total: RoomAreaSummary;
|
||||||
|
byBuilding: Map<string | null, RoomAreaSummary>;
|
||||||
|
byFloor: Map<string | null, RoomAreaSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
|
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
|
||||||
/** null for floors without (known) building. */
|
/** null for floors without (known) building. */
|
||||||
|
|
@ -262,6 +275,59 @@ export function countRoomsByFloor(
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sums only known room areas and counts missing values separately. Rooms with
|
||||||
|
* no known floor/building are grouped under null for the corresponding UI
|
||||||
|
* entries "Ohne Etage" and "Ohne Gebäude".
|
||||||
|
*/
|
||||||
|
export function summarizeRoomAreas(
|
||||||
|
rooms: ReadonlyArray<RoomAreaLike>,
|
||||||
|
floors: ReadonlyArray<FloorLike>,
|
||||||
|
buildings: ReadonlyArray<BuildingLike>
|
||||||
|
): StructureAreaSummaries {
|
||||||
|
const buildingIds = toIdSet(buildings);
|
||||||
|
const floorsById = new Map(floors.map((floor) => [floor.id, floor]));
|
||||||
|
const byBuilding = new Map<string | null, RoomAreaSummary>();
|
||||||
|
const byFloor = new Map<string | null, RoomAreaSummary>();
|
||||||
|
const total = emptyAreaSummary();
|
||||||
|
|
||||||
|
for (const room of rooms) {
|
||||||
|
const floor = room.floorId === null ? undefined : floorsById.get(room.floorId);
|
||||||
|
const floorId = floor?.id ?? null;
|
||||||
|
const buildingId = floor
|
||||||
|
? resolveFloorBuildingId(floor, buildingIds)
|
||||||
|
: null;
|
||||||
|
addRoomArea(total, room.areaM2);
|
||||||
|
addRoomArea(summaryFor(byFloor, floorId), room.areaM2);
|
||||||
|
addRoomArea(summaryFor(byBuilding, buildingId), room.areaM2);
|
||||||
|
}
|
||||||
|
return { total, byBuilding, byFloor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyAreaSummary(): RoomAreaSummary {
|
||||||
|
return { areaM2: 0, roomCount: 0, missingAreaCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFor(
|
||||||
|
summaries: Map<string | null, RoomAreaSummary>,
|
||||||
|
key: string | null
|
||||||
|
) {
|
||||||
|
const existing = summaries.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = emptyAreaSummary();
|
||||||
|
summaries.set(key, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRoomArea(summary: RoomAreaSummary, areaM2: number | null) {
|
||||||
|
summary.roomCount += 1;
|
||||||
|
if (areaM2 === null) {
|
||||||
|
summary.missingAreaCount += 1;
|
||||||
|
} else {
|
||||||
|
summary.areaM2 += areaM2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */
|
/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */
|
||||||
export function describeRoomLocation(
|
export function describeRoomLocation(
|
||||||
room: RoomLike,
|
room: RoomLike,
|
||||||
|
|
|
||||||
30
src/frontend/utils/room-area.ts
Normal file
30
src/frontend/utils/room-area.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export type RoomAreaParseResult =
|
||||||
|
| { ok: true; value: number | null }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/** Parses an optional German decimal without rounding the stored value. */
|
||||||
|
export function parseRoomAreaInput(text: string): RoomAreaParseResult {
|
||||||
|
const normalized = text.trim();
|
||||||
|
if (!normalized) return { ok: true, value: null };
|
||||||
|
if (!/^(?:\d+(?:,\d*)?|,\d+)$/.test(normalized)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Bitte eine nicht negative Zahl mit Komma eingeben.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const value = Number(normalized.replace(",", "."));
|
||||||
|
return Number.isFinite(value) && value >= 0
|
||||||
|
? { ok: true, value }
|
||||||
|
: { ok: false, error: "Die Fläche muss mindestens 0 m² betragen." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRoomArea(areaM2: number): string {
|
||||||
|
return areaM2.toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatOptionalRoomAreaInput(areaM2: number | null | undefined): string {
|
||||||
|
return areaM2 === null || areaM2 === undefined ? "" : formatRoomArea(areaM2);
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ export async function updateRoom(req: Request, res: Response) {
|
||||||
floorId: parsed.data.floorId,
|
floorId: parsed.data.floorId,
|
||||||
roomNumber: parsed.data.roomNumber,
|
roomNumber: parsed.data.roomNumber,
|
||||||
roomName: parsed.data.roomName,
|
roomName: parsed.data.roomName,
|
||||||
|
areaM2: parsed.data.areaM2 === undefined ? current.areaM2 : parsed.data.areaM2,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const result = projectCommandService.executeUser({
|
const result = projectCommandService.executeUser({
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export const createRoomSchema = z
|
||||||
floorId: z.string().trim().min(1).optional(),
|
floorId: z.string().trim().min(1).optional(),
|
||||||
roomNumber: z.string().trim().min(1).max(50),
|
roomNumber: z.string().trim().min(1).max(50),
|
||||||
roomName: z.string().trim().min(1).max(200),
|
roomName: z.string().trim().min(1).max(200),
|
||||||
|
areaM2: z.number().finite().nonnegative().nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
@ -144,6 +145,7 @@ export const updateRoomSchema = z
|
||||||
floorId: z.string().trim().min(1).nullable(),
|
floorId: z.string().trim().min(1).nullable(),
|
||||||
roomNumber: z.string().trim().min(1).max(50),
|
roomNumber: z.string().trim().min(1).max(50),
|
||||||
roomName: z.string().trim().min(1).max(200),
|
roomName: z.string().trim().min(1).max(200),
|
||||||
|
areaM2: z.number().finite().nonnegative().nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
normalizeBuildingSelection,
|
normalizeBuildingSelection,
|
||||||
normalizeFloorSelection,
|
normalizeFloorSelection,
|
||||||
showsRoomsWithoutFloorEntry,
|
showsRoomsWithoutFloorEntry,
|
||||||
|
summarizeRoomAreas,
|
||||||
} from "../src/frontend/utils/building-structure.js";
|
} from "../src/frontend/utils/building-structure.js";
|
||||||
|
|
||||||
function building(id: string, name: string, sortOrder: number): BuildingDto {
|
function building(id: string, name: string, sortOrder: number): BuildingDto {
|
||||||
|
|
@ -36,13 +37,19 @@ function floor(
|
||||||
return { id, projectId: "project-1", name, sortOrder, buildingId };
|
return { id, projectId: "project-1", name, sortOrder, buildingId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function room(id: string, floorId: string | null, roomNumber = id): RoomDto {
|
function room(
|
||||||
|
id: string,
|
||||||
|
floorId: string | null,
|
||||||
|
roomNumber = id,
|
||||||
|
areaM2: number | null = null
|
||||||
|
): RoomDto {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
floorId,
|
floorId,
|
||||||
roomNumber,
|
roomNumber,
|
||||||
roomName: `Raum ${roomNumber}`,
|
roomName: `Raum ${roomNumber}`,
|
||||||
|
areaM2,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -263,6 +270,43 @@ describe("building structure view", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sums known areas and counts missing areas per floor and building", () => {
|
||||||
|
const areaRooms = [
|
||||||
|
room("a", "f-a-eg", "a", 20.25),
|
||||||
|
room("b", "f-a-eg"),
|
||||||
|
room("c", "f-free", "c", 5.5),
|
||||||
|
room("d", null, "d", 3),
|
||||||
|
room("e", "f-deleted"),
|
||||||
|
];
|
||||||
|
const summaries = summarizeRoomAreas(areaRooms, floors, buildings);
|
||||||
|
|
||||||
|
assert.deepEqual(summaries.total, {
|
||||||
|
areaM2: 28.75,
|
||||||
|
roomCount: 5,
|
||||||
|
missingAreaCount: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(summaries.byFloor.get("f-a-eg"), {
|
||||||
|
areaM2: 20.25,
|
||||||
|
roomCount: 2,
|
||||||
|
missingAreaCount: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(summaries.byFloor.get(null), {
|
||||||
|
areaM2: 3,
|
||||||
|
roomCount: 2,
|
||||||
|
missingAreaCount: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(summaries.byBuilding.get("b-a"), {
|
||||||
|
areaM2: 20.25,
|
||||||
|
roomCount: 2,
|
||||||
|
missingAreaCount: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(summaries.byBuilding.get(null), {
|
||||||
|
areaM2: 8.5,
|
||||||
|
roomCount: 3,
|
||||||
|
missingAreaCount: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("describes a room location with floor and building", () => {
|
it("describes a room location with floor and building", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
describeRoomLocation(room("r", "f-a-og"), floors, buildings),
|
describeRoomLocation(room("r", "f-a-og"), floors, buildings),
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,13 @@ it("upgrades a populated pre-print database through the current schema without c
|
||||||
VALUES ('b', 'p', 'Existing board', 0.7);
|
VALUES ('b', 'p', 'Existing board', 0.7);
|
||||||
INSERT INTO floors (id, project_id, name, sort_order)
|
INSERT INTO floors (id, project_id, name, sort_order)
|
||||||
VALUES ('f', 'p', 'Existing floor', 3);
|
VALUES ('f', 'p', 'Existing floor', 3);
|
||||||
|
INSERT INTO rooms (id, project_id, floor_id, room_number, room_name)
|
||||||
|
VALUES ('r', 'p', 'f', '101', 'Existing room');
|
||||||
`);
|
`);
|
||||||
const oldProject = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
|
const oldProject = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
|
||||||
const oldBoard = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
|
const oldBoard = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
|
||||||
const oldFloor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
|
const oldFloor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
|
||||||
|
const oldRoom = context.sqlite.prepare("SELECT * FROM rooms").get() as Record<string, unknown>;
|
||||||
const backup = path.join(staging, "before-print.db");
|
const backup = path.join(staging, "before-print.db");
|
||||||
files.push(backup);
|
files.push(backup);
|
||||||
await context.sqlite.backup(backup);
|
await context.sqlite.backup(backup);
|
||||||
|
|
@ -40,12 +43,15 @@ it("upgrades a populated pre-print database through the current schema without c
|
||||||
const project = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
|
const project = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
|
||||||
const board = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
|
const board = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
|
||||||
const floor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
|
const floor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
|
||||||
|
const room = context.sqlite.prepare("SELECT * FROM rooms").get() as Record<string, unknown>;
|
||||||
for (const [key, value] of Object.entries(oldProject)) assert.deepEqual(project[key], value);
|
for (const [key, value] of Object.entries(oldProject)) assert.deepEqual(project[key], value);
|
||||||
for (const [key, value] of Object.entries(oldBoard)) assert.deepEqual(board[key], value);
|
for (const [key, value] of Object.entries(oldBoard)) assert.deepEqual(board[key], value);
|
||||||
for (const [key, value] of Object.entries(oldFloor)) assert.deepEqual(floor[key], value);
|
for (const [key, value] of Object.entries(oldFloor)) assert.deepEqual(floor[key], value);
|
||||||
|
for (const [key, value] of Object.entries(oldRoom)) assert.deepEqual(room[key], value);
|
||||||
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
|
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
|
||||||
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
|
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
|
||||||
assert.equal(floor.building_id, null);
|
assert.equal(floor.building_id, null);
|
||||||
|
assert.equal(room.area_m2, null);
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ import {
|
||||||
createProjectRoomInsertProjectCommand,
|
createProjectRoomInsertProjectCommand,
|
||||||
createProjectRoomSnapshot,
|
createProjectRoomSnapshot,
|
||||||
createProjectRoomUpdateProjectCommand,
|
createProjectRoomUpdateProjectCommand,
|
||||||
|
projectRoomCommandSchemaVersion,
|
||||||
|
projectRoomDeleteCommandType,
|
||||||
|
projectRoomUpdateCommandType,
|
||||||
|
type LegacyProjectRoomDeleteProjectCommand,
|
||||||
|
type LegacyProjectRoomUpdateProjectCommand,
|
||||||
} from "../src/domain/models/project-location-structure-project-command.model.js";
|
} from "../src/domain/models/project-location-structure-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
createFloor,
|
createFloor,
|
||||||
|
|
@ -66,10 +71,12 @@ describe("project-location structure project command", () => {
|
||||||
floorId: floor.id,
|
floorId: floor.id,
|
||||||
roomNumber: " 001 ",
|
roomNumber: " 001 ",
|
||||||
roomName: " Technik ",
|
roomName: " Technik ",
|
||||||
|
areaM2: 23.456,
|
||||||
});
|
});
|
||||||
assert.equal(floor.name, "EG");
|
assert.equal(floor.name, "EG");
|
||||||
assert.equal(room.roomNumber, "001");
|
assert.equal(room.roomNumber, "001");
|
||||||
assert.equal(room.roomName, "Technik");
|
assert.equal(room.roomName, "Technik");
|
||||||
|
assert.equal(room.areaM2, 23.456);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createProjectFloorInsertProjectCommand({
|
createProjectFloorInsertProjectCommand({
|
||||||
|
|
@ -113,6 +120,7 @@ describe("project-location structure project command", () => {
|
||||||
floorId: "floor-1",
|
floorId: "floor-1",
|
||||||
roomNumber: "001",
|
roomNumber: "001",
|
||||||
roomName: "Technik",
|
roomName: "Technik",
|
||||||
|
areaM2: 23.456,
|
||||||
},
|
},
|
||||||
8
|
8
|
||||||
);
|
);
|
||||||
|
|
@ -120,7 +128,7 @@ describe("project-location structure project command", () => {
|
||||||
await updateRoom(
|
await updateRoom(
|
||||||
"project-1",
|
"project-1",
|
||||||
"room-1",
|
"room-1",
|
||||||
{ roomNumber: "101", roomName: "Büro" },
|
{ roomNumber: "101", roomName: "Büro", areaM2: null },
|
||||||
10
|
10
|
||||||
);
|
);
|
||||||
await deleteFloor("project-1", "floor-1", 11);
|
await deleteFloor("project-1", "floor-1", 11);
|
||||||
|
|
@ -141,6 +149,7 @@ describe("project-location structure project command", () => {
|
||||||
floorId: "floor-1",
|
floorId: "floor-1",
|
||||||
roomNumber: "001",
|
roomNumber: "001",
|
||||||
roomName: "Technik",
|
roomName: "Technik",
|
||||||
|
areaM2: 23.456,
|
||||||
expectedRevision: 8,
|
expectedRevision: 8,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -155,6 +164,7 @@ describe("project-location structure project command", () => {
|
||||||
body: {
|
body: {
|
||||||
roomNumber: "101",
|
roomNumber: "101",
|
||||||
roomName: "Büro",
|
roomName: "Büro",
|
||||||
|
areaM2: null,
|
||||||
floorId: null,
|
floorId: null,
|
||||||
expectedRevision: 10,
|
expectedRevision: 10,
|
||||||
},
|
},
|
||||||
|
|
@ -199,6 +209,7 @@ describe("project-location structure project command", () => {
|
||||||
floorId: null,
|
floorId: null,
|
||||||
roomNumber: "002",
|
roomNumber: "002",
|
||||||
roomName: "Lager",
|
roomName: "Lager",
|
||||||
|
areaM2: 48.125,
|
||||||
};
|
};
|
||||||
const roomCommand = createProjectRoomUpdateProjectCommand(room, updatedRoom);
|
const roomCommand = createProjectRoomUpdateProjectCommand(room, updatedRoom);
|
||||||
const roomResult = repository.execute({
|
const roomResult = repository.execute({
|
||||||
|
|
@ -427,6 +438,64 @@ describe("project-location structure project command", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("upgrades version-1 room commands without overwriting a newer area", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectLocationStructureProjectCommandRepository(context.db);
|
||||||
|
const history = new ProjectHistoryRepository(context.db);
|
||||||
|
const room = createProjectRoomSnapshot("project-1", {
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Technik",
|
||||||
|
});
|
||||||
|
context.db.insert(rooms).values(room).run();
|
||||||
|
const { areaM2: _expectedArea, ...legacyExpected } = room;
|
||||||
|
const legacyTarget = { ...legacyExpected, roomName: "Technik alt" };
|
||||||
|
const legacyUpdate: LegacyProjectRoomUpdateProjectCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: projectRoomUpdateCommandType,
|
||||||
|
payload: { expected: legacyExpected, target: legacyTarget },
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: legacyUpdate,
|
||||||
|
});
|
||||||
|
assert.equal(updated.inverse.schemaVersion, projectRoomCommandSchemaVersion);
|
||||||
|
assert.equal(context.db.select().from(rooms).get()?.areaM2, null);
|
||||||
|
|
||||||
|
const undo = history.getNextCommand("project-1", "undo");
|
||||||
|
assert.ok(undo);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: undo.changeSetId,
|
||||||
|
command: updated.inverse,
|
||||||
|
});
|
||||||
|
context.db.update(rooms).set({ areaM2: 12.5 }).where(eq(rooms.id, room.id)).run();
|
||||||
|
const legacyDelete: LegacyProjectRoomDeleteProjectCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: projectRoomDeleteCommandType,
|
||||||
|
payload: { room: legacyExpected },
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "user",
|
||||||
|
command: legacyDelete,
|
||||||
|
}),
|
||||||
|
/changed before deletion/
|
||||||
|
);
|
||||||
|
assert.equal(context.db.select().from(rooms).get()?.areaM2, 12.5);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects foreign, stale and changed locations without partial writes", () => {
|
it("rejects foreign, stale and changed locations without partial writes", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ function createTestDatabase(): DatabaseContext {
|
||||||
floorId: "floor-1",
|
floorId: "floor-1",
|
||||||
roomNumber: "001",
|
roomNumber: "001",
|
||||||
roomName: "Technik",
|
roomName: "Technik",
|
||||||
|
areaM2: 31.75,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
context.db
|
context.db
|
||||||
|
|
@ -461,12 +462,16 @@ describe("project snapshot repository", () => {
|
||||||
distributionBoards: Array<Record<string, unknown>>;
|
distributionBoards: Array<Record<string, unknown>>;
|
||||||
buildings?: unknown;
|
buildings?: unknown;
|
||||||
floors: Array<Record<string, unknown>>;
|
floors: Array<Record<string, unknown>>;
|
||||||
|
rooms: Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
payload.schemaVersion = 5;
|
payload.schemaVersion = 5;
|
||||||
delete payload.buildings;
|
delete payload.buildings;
|
||||||
for (const floor of payload.floors) {
|
for (const floor of payload.floors) {
|
||||||
delete floor.buildingId;
|
delete floor.buildingId;
|
||||||
}
|
}
|
||||||
|
for (const room of payload.rooms) {
|
||||||
|
delete room.areaM2;
|
||||||
|
}
|
||||||
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
|
||||||
delete payload.project[field];
|
delete payload.project[field];
|
||||||
}
|
}
|
||||||
|
|
@ -597,6 +602,7 @@ describe("portable project transfer", () => {
|
||||||
copied.state.circuits[0].deviceRows[0].roomId,
|
copied.state.circuits[0].deviceRows[0].roomId,
|
||||||
copied.state.rooms[0].id
|
copied.state.rooms[0].id
|
||||||
);
|
);
|
||||||
|
assert.equal(copied.state.rooms[0].areaM2, 31.75);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
copied.state.distributionBoards[0].floorId,
|
copied.state.distributionBoards[0].floorId,
|
||||||
copied.state.floors[0].id
|
copied.state.floors[0].id
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ function createTestDatabase(): DatabaseContext {
|
||||||
floorId: "floor-1",
|
floorId: "floor-1",
|
||||||
roomNumber: "001",
|
roomNumber: "001",
|
||||||
roomName: "Technik",
|
roomName: "Technik",
|
||||||
|
areaM2: 31.75,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
context.db
|
context.db
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,14 @@ function minimalSnapshot() {
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
buildingId: string | null;
|
buildingId: string | null;
|
||||||
}>,
|
}>,
|
||||||
rooms: [],
|
rooms: [] as Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
floorId: string | null;
|
||||||
|
roomNumber: string;
|
||||||
|
roomName: string;
|
||||||
|
areaM2: number | null;
|
||||||
|
}>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,6 +450,26 @@ describe("project state snapshot model", () => {
|
||||||
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
|
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps room areas and upgrades snapshot version 7 with an empty area", () => {
|
||||||
|
const current = minimalSnapshot();
|
||||||
|
current.rooms.push({
|
||||||
|
id: "room-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
floorId: null,
|
||||||
|
roomNumber: "001",
|
||||||
|
roomName: "Technik",
|
||||||
|
areaM2: 23.456,
|
||||||
|
});
|
||||||
|
assert.equal(parseProjectStateSnapshot(current).rooms[0]?.areaM2, 23.456);
|
||||||
|
|
||||||
|
const previous = structuredClone(current);
|
||||||
|
previous.schemaVersion = 7 as typeof previous.schemaVersion;
|
||||||
|
delete (previous.rooms[0] as Partial<(typeof previous.rooms)[number]>).areaM2;
|
||||||
|
const upgraded = parseProjectStateSnapshot(previous);
|
||||||
|
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||||
|
assert.equal(upgraded.rooms[0]?.areaM2, null);
|
||||||
|
});
|
||||||
|
|
||||||
it("upgrades snapshot version 6 without buildings", () => {
|
it("upgrades snapshot version 6 without buildings", () => {
|
||||||
const snapshot = minimalSnapshot();
|
const snapshot = minimalSnapshot();
|
||||||
snapshot.floors.push({
|
snapshot.floors.push({
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ describe("Revit CSV modal presentation", () => {
|
||||||
objects: [],
|
objects: [],
|
||||||
existing: {
|
existing: {
|
||||||
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
|
floors: [{ id: "floor-1", name: "EG", sortOrder: 0 }],
|
||||||
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro" }],
|
rooms: [{ id: "room-1", floorId: "floor-1", roomNumber: "101", roomName: "Büro", areaM2: null }],
|
||||||
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
|
distributionBoards: [{ id: "board-1", name: "UV-01", floorId: "floor-1" }],
|
||||||
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
|
projectDevices: [{ id: "device-1", name: "Steckdose", displayName: "Steckdose", category: "single_phase", connectionKind: null, powerPerUnit: 100 }],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
59
tests/room-area.test.ts
Normal file
59
tests/room-area.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { RoomModal } from "../src/frontend/components/room-modal.js";
|
||||||
|
import {
|
||||||
|
formatRoomArea,
|
||||||
|
parseRoomAreaInput,
|
||||||
|
} from "../src/frontend/utils/room-area.js";
|
||||||
|
import {
|
||||||
|
createRoomSchema,
|
||||||
|
updateRoomSchema,
|
||||||
|
} from "../src/shared/validation/project-structure.schemas.js";
|
||||||
|
|
||||||
|
describe("room area input", () => {
|
||||||
|
it("parses optional German decimals without rounding", () => {
|
||||||
|
assert.deepEqual(parseRoomAreaInput(""), { ok: true, value: null });
|
||||||
|
assert.deepEqual(parseRoomAreaInput(" 23,456 "), { ok: true, value: 23.456 });
|
||||||
|
assert.deepEqual(parseRoomAreaInput(",5"), { ok: true, value: 0.5 });
|
||||||
|
assert.deepEqual(parseRoomAreaInput("0"), { ok: true, value: 0 });
|
||||||
|
for (const value of ["-1", "23.45", "abc", "1,2,3"]) {
|
||||||
|
assert.equal(parseRoomAreaInput(value).ok, false, value);
|
||||||
|
}
|
||||||
|
assert.equal(formatRoomArea(23.456), "23,46");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates finite non-negative API values and remains backward compatible", () => {
|
||||||
|
const base = {
|
||||||
|
expectedRevision: 1,
|
||||||
|
roomNumber: "101",
|
||||||
|
roomName: "Büro",
|
||||||
|
};
|
||||||
|
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: 23.456 }).success, true);
|
||||||
|
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: null }).success, true);
|
||||||
|
assert.equal(createRoomSchema.safeParse(base).success, true);
|
||||||
|
assert.equal(updateRoomSchema.safeParse({ ...base, floorId: null }).success, true);
|
||||||
|
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: -1 }).success, false);
|
||||||
|
assert.equal(createRoomSchema.safeParse({ ...base, areaM2: Number.NaN }).success, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the optional area with two decimals in the shared room dialog", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(RoomModal, {
|
||||||
|
floors: [],
|
||||||
|
initialValues: {
|
||||||
|
roomNumber: "101",
|
||||||
|
roomName: "Büro",
|
||||||
|
areaM2: 23.4,
|
||||||
|
},
|
||||||
|
isSaving: false,
|
||||||
|
onClose: () => undefined,
|
||||||
|
onSave: async () => undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.match(markup, /Fläche \[m²\]/);
|
||||||
|
assert.match(markup, /value="23,40"/);
|
||||||
|
assert.match(markup, /Netto-Raumfläche/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue