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

@ -6,6 +6,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { BuildingRepository } from "../../db/repositories/building.repository.js";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
@ -26,6 +27,7 @@ export const distributionBoardComponentRepository =
new DistributionBoardComponentRepository(db);
export const distributionBoardRepository =
new DistributionBoardRepository(db);
export const buildingRepository = new BuildingRepository(db);
export const floorRepository = new FloorRepository(db);
export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db);

View file

@ -0,0 +1,111 @@
import type { Request, Response } from "express";
import {
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectBuildingUpdateProjectCommand,
} from "../../domain/models/project-location-structure-project-command.model.js";
import {
createBuildingSchema,
deleteProjectLocationSchema,
updateBuildingSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { buildingRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function listBuildingsByProject(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const result = await buildingRepository.listByProject(projectId);
return res.json(result);
}
export async function createBuilding(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const existingBuildings = await buildingRepository.listByProject(projectId);
const nextSortOrder = existingBuildings.length
? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1
: 0;
const building = createProjectBuildingSnapshot(
projectId,
parsed.data.name,
nextSortOrder
);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude anlegen",
command: createProjectBuildingInsertProjectCommand(building),
});
return res.status(201).json({ ...result, building });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = updateBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
const target = { ...current, name: parsed.data.name };
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude bearbeiten",
command: createProjectBuildingUpdateProjectCommand(current, target),
});
return res.json({ ...result, building: target });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function deleteBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = deleteProjectLocationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude löschen",
command: createProjectBuildingDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}

View file

@ -42,7 +42,8 @@ export async function createFloor(req: Request, res: Response) {
const floor = createProjectFloorSnapshot(
projectId,
parsed.data.name,
nextSortOrder
nextSortOrder,
parsed.data.buildingId ?? null
);
try {
const result = projectCommandService.executeUser({
@ -70,7 +71,14 @@ export async function updateFloor(req: Request, res: Response) {
if (!current) {
return res.status(404).json({ error: "Floor not found" });
}
const target = { ...current, name: parsed.data.name };
const target = {
...current,
name: parsed.data.name,
buildingId:
parsed.data.buildingId === undefined
? current.buildingId
: parsed.data.buildingId,
};
try {
const result = projectCommandService.executeUser({
projectId,

View file

@ -13,6 +13,12 @@ import {
updateDistributionBoard,
} from "../controllers/distribution-board.controller.js";
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
import {
createBuilding,
deleteBuilding,
listBuildingsByProject,
updateBuilding,
} from "../controllers/building.controller.js";
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
@ -100,6 +106,10 @@ projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier",
getNextCircuitIdentifier
);
projectRouter.get("/:projectId/buildings", listBuildingsByProject);
projectRouter.post("/:projectId/buildings", createBuilding);
projectRouter.put("/:projectId/buildings/:buildingId", updateBuilding);
projectRouter.delete("/:projectId/buildings/:buildingId", deleteBuilding);
projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor);