Add buildings above floors and a building structure page

Projects with several buildings could not be structured: floors and rooms
hung directly off the project. Floors can now belong to a building
(migration 0009: table buildings, nullable floors.building_id). Rooms and
distribution boards keep their floor; their building follows from it.
Existing floors stay without a building.

Buildings use the location-structure command store with
project-building.insert/.update/.delete, inverses and persistent Undo/Redo;
deleting a building with assigned floors is rejected. Floor commands carry
buildingId since command version 2. Version 1 floor commands from existing
history predate buildings and are upgraded with buildingId = null before
they are executed and persisted, so forward and inverse keep one version.

Project snapshot schema 7 stores buildings and floor.buildingId; versions
1-6 are upgraded without buildings. Restore and portable transfer include
buildings, and the transfer remaps building ids.

Buildings, floors and rooms move from the project overview to their own
page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume)
that filter each other, and a sidebar entry. Floor selects in the board and
room dialogs are grouped by building. Unchanged dialogs close without a
command. Details in docs/building-structure.md; see issue #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 12:52:34 +02:00
parent 7692699ee2
commit cd89c4a499
42 changed files with 5883 additions and 373 deletions

View file

@ -8,10 +8,22 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
/** Removes the fields introduced by snapshot schema version 6. */
function withoutVersionSixFields<
T extends { project: object; distributionBoards: object[] },
/** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields<
T extends { floors: object[] },
>(snapshot: T): T {
delete (snapshot as Record<string, unknown>).buildings;
for (const floor of snapshot.floors) {
delete (floor as Record<string, unknown>).buildingId;
}
return snapshot;
}
/** Removes the fields introduced by snapshot schema versions 6 and 7. */
function withoutVersionSixFields<
T extends { project: object; distributionBoards: object[]; floors: object[] },
>(snapshot: T): T {
withoutVersionSevenFields(snapshot);
const project = snapshot.project as Record<string, unknown>;
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete project[field];
@ -66,7 +78,19 @@ function minimalSnapshot() {
distributionBoardComponentProtectionDevices: [],
circuits: [],
projectDevices: [],
floors: [],
buildings: [] as Array<{
id: string;
projectId: string;
name: string;
sortOrder: number;
}>,
floors: [] as Array<{
id: string;
projectId: string;
name: string;
sortOrder: number;
buildingId: string | null;
}>,
rooms: [],
};
}
@ -306,7 +330,10 @@ describe("project state snapshot model", () => {
delete (outdated.project as Record<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated));
assert.throws(() =>
parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 })
parseProjectStateSnapshot({
...minimalSnapshot(),
schemaVersion: projectStateSnapshotSchemaVersion + 1,
})
);
});
@ -355,12 +382,99 @@ describe("project state snapshot model", () => {
projectId: "project-2",
name: "EG",
sortOrder: 10,
buildingId: null,
});
assert.throws(
() => parseProjectStateSnapshot(wrongOwner),
/floor belongs to a different project/
);
});
it("keeps buildings and validates floor building references", () => {
const snapshot = minimalSnapshot();
snapshot.buildings.push({
id: "building-1",
projectId: "project-1",
name: "Haus A",
sortOrder: 0,
});
snapshot.floors.push(
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: "building-1",
},
{
id: "floor-2",
projectId: "project-1",
name: "Keller",
sortOrder: 1,
buildingId: null,
}
);
const parsed = deserializeProjectStateSnapshot(
serializeProjectStateSnapshot(parseProjectStateSnapshot(snapshot))
);
assert.deepEqual(parsed.buildings, snapshot.buildings);
assert.deepEqual(
parsed.floors.map((floor) => floor.buildingId),
["building-1", null]
);
const unknownBuilding = structuredClone(snapshot);
unknownBuilding.floors[0]!.buildingId = "building-x";
assert.throws(
() => parseProjectStateSnapshot(unknownBuilding),
/floor building/
);
const foreignBuilding = structuredClone(snapshot);
foreignBuilding.buildings[0]!.projectId = "project-2";
assert.throws(
() => parseProjectStateSnapshot(foreignBuilding),
/building belongs to a different project/
);
const floorWithoutBuildingField = structuredClone(snapshot);
delete (floorWithoutBuildingField.floors[1] as Record<string, unknown>)
.buildingId;
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
});
it("upgrades snapshot version 6 without buildings", () => {
const snapshot = minimalSnapshot();
snapshot.floors.push({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: null,
});
const previous = withoutVersionSevenFields(structuredClone(snapshot));
previous.schemaVersion = 6 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(upgraded.buildings, []);
assert.deepEqual(upgraded.floors, [
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: null,
},
]);
// Version 6 cover values survive the upgrade.
assert.equal(upgraded.project.workStage, "LPH 5");
const mislabeled = structuredClone(snapshot);
mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled));
});
});
describe("named project snapshot request", () => {