Add optional room areas

This commit is contained in:
Julian Appel 2026-09-27 20:00:03 +02:00
parent a6500e2b26
commit dce31ee143
26 changed files with 3669 additions and 78 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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 ? (

View file

@ -0,0 +1 @@
ALTER TABLE `rooms` ADD `area_m2` real;

File diff suppressed because it is too large Load diff

View file

@ -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
} }
] ]
} }

View file

@ -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;
} }

View file

@ -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"),
}); });

View file

@ -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);
} }

View file

@ -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 {

View file

@ -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">

View file

@ -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 {

View file

@ -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,

View 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);
}

View file

@ -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({

View file

@ -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();

View file

@ -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),

View file

@ -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);

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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({

View file

@ -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
View 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/);
});
});