forked from jappel/leistungsbilanz-ts
Add buildings above floors and a building structure page
Projects with several buildings could not be structured: floors and rooms hung directly off the project. Floors can now belong to a building (migration 0009: table buildings, nullable floors.building_id). Rooms and distribution boards keep their floor; their building follows from it. Existing floors stay without a building. Buildings use the location-structure command store with project-building.insert/.update/.delete, inverses and persistent Undo/Redo; deleting a building with assigned floors is rejected. Floor commands carry buildingId since command version 2. Version 1 floor commands from existing history predate buildings and are upgraded with buildingId = null before they are executed and persisted, so forward and inverse keep one version. Project snapshot schema 7 stores buildings and floor.buildingId; versions 1-6 are upgraded without buildings. Restore and portable transfer include buildings, and the transfer remaps building ids. Buildings, floors and rooms move from the project overview to their own page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume) that filter each other, and a sidebar entry. Floor selects in the board and room dialogs are grouped by building. Unchanged dialogs close without a command. Details in docs/building-structure.md; see issue #6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7692699ee2
commit
cd89c4a499
42 changed files with 5883 additions and 373 deletions
9
src/db/migrations/0009_buildings.sql
Normal file
9
src/db/migrations/0009_buildings.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE `buildings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;
|
||||
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -64,6 +64,13 @@
|
|||
"when": 1790411560953,
|
||||
"tag": "0008_project_cover_and_office_profile",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1790419185882,
|
||||
"tag": "0009_buildings",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
27
src/db/repositories/building.repository.ts
Normal file
27
src/db/repositories/building.repository.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
|
||||
export class BuildingRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
return this.database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(eq(buildings.projectId, projectId))
|
||||
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
|
||||
}
|
||||
|
||||
async findById(projectId: string, buildingId: string) {
|
||||
return (
|
||||
this.database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(
|
||||
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
|
||||
)
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,34 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertProjectBuildingDeleteProjectCommand,
|
||||
assertProjectBuildingInsertProjectCommand,
|
||||
assertProjectBuildingUpdateProjectCommand,
|
||||
assertProjectFloorDeleteProjectCommand,
|
||||
assertProjectFloorInsertProjectCommand,
|
||||
assertProjectFloorUpdateProjectCommand,
|
||||
assertProjectRoomDeleteProjectCommand,
|
||||
assertProjectRoomInsertProjectCommand,
|
||||
assertProjectRoomUpdateProjectCommand,
|
||||
createProjectBuildingDeleteProjectCommand,
|
||||
createProjectBuildingInsertProjectCommand,
|
||||
createProjectBuildingUpdateProjectCommand,
|
||||
createProjectFloorDeleteProjectCommand,
|
||||
createProjectFloorInsertProjectCommand,
|
||||
createProjectFloorUpdateProjectCommand,
|
||||
createProjectRoomDeleteProjectCommand,
|
||||
createProjectRoomInsertProjectCommand,
|
||||
createProjectRoomUpdateProjectCommand,
|
||||
projectBuildingDeleteCommandType,
|
||||
projectBuildingInsertCommandType,
|
||||
projectBuildingUpdateCommandType,
|
||||
projectFloorDeleteCommandType,
|
||||
projectFloorInsertCommandType,
|
||||
projectFloorUpdateCommandType,
|
||||
projectRoomDeleteCommandType,
|
||||
projectRoomInsertCommandType,
|
||||
projectRoomUpdateCommandType,
|
||||
upgradeProjectFloorCommand,
|
||||
type ProjectBuildingSnapshot,
|
||||
type ProjectFloorSnapshot,
|
||||
type ProjectLocationStructureProjectCommand,
|
||||
type ProjectRoomSnapshot,
|
||||
|
|
@ -27,6 +38,7 @@ import type {
|
|||
ProjectLocationStructureProjectCommandStore,
|
||||
} from "../../domain/ports/project-location-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
|
|
@ -40,11 +52,14 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteProjectLocationStructureCommandInput) {
|
||||
// Forward and inverse are persisted with one schema version, so a
|
||||
// version 1 floor command from older history is upgraded first.
|
||||
const command = toCurrentCommandVersion(input.command);
|
||||
return executeProjectCommandTransaction(
|
||||
this.database,
|
||||
input,
|
||||
{ ...input, command },
|
||||
(tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
this.applyCommand(tx, input.projectId, command)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -54,23 +69,43 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
command: ProjectLocationStructureProjectCommand
|
||||
): ProjectLocationStructureProjectCommand {
|
||||
switch (command.type) {
|
||||
case projectFloorInsertCommandType:
|
||||
case projectBuildingInsertCommandType:
|
||||
assertProjectBuildingInsertProjectCommand(command);
|
||||
return this.insertBuilding(database, projectId, command.payload.building);
|
||||
case projectBuildingDeleteCommandType:
|
||||
assertProjectBuildingDeleteProjectCommand(command);
|
||||
return this.deleteBuilding(database, projectId, command.payload.building);
|
||||
case projectBuildingUpdateCommandType:
|
||||
assertProjectBuildingUpdateProjectCommand(command);
|
||||
return this.updateBuilding(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(command);
|
||||
const current = upgradeProjectFloorCommand(command);
|
||||
return this.insertFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
current.payload.floor
|
||||
);
|
||||
case projectFloorDeleteCommandType:
|
||||
}
|
||||
case projectFloorDeleteCommandType: {
|
||||
assertProjectFloorDeleteProjectCommand(command);
|
||||
const current = upgradeProjectFloorCommand(command);
|
||||
return this.deleteFloor(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.floor
|
||||
current.payload.floor
|
||||
);
|
||||
case projectFloorUpdateCommandType:
|
||||
}
|
||||
case projectFloorUpdateCommandType: {
|
||||
assertProjectFloorUpdateProjectCommand(command);
|
||||
return this.updateFloor(database, projectId, command.payload.expected, command.payload.target);
|
||||
const current = upgradeProjectFloorCommand(command);
|
||||
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
|
||||
}
|
||||
case projectRoomInsertCommandType:
|
||||
assertProjectRoomInsertProjectCommand(command);
|
||||
return this.insertRoom(
|
||||
|
|
@ -100,6 +135,7 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
if (floor.projectId !== projectId) {
|
||||
throw new Error("Project-floor snapshot does not belong to project.");
|
||||
}
|
||||
this.assertBuildingInProject(database, projectId, floor.buildingId);
|
||||
const existing = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
|
|
@ -167,6 +203,104 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
return createProjectFloorInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private insertBuilding(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
building: ProjectBuildingSnapshot
|
||||
) {
|
||||
this.assertProjectExists(database, projectId);
|
||||
if (building.projectId !== projectId) {
|
||||
throw new Error("Project-building snapshot does not belong to project.");
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: buildings.id })
|
||||
.from(buildings)
|
||||
.where(eq(buildings.id, building.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Project-building id already exists.");
|
||||
}
|
||||
database.insert(buildings).values(building).run();
|
||||
return createProjectBuildingDeleteProjectCommand(building);
|
||||
}
|
||||
|
||||
private deleteBuilding(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectBuildingSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project building changed before deletion.");
|
||||
}
|
||||
const referencedFloor = database
|
||||
.select({ id: floors.id })
|
||||
.from(floors)
|
||||
.where(eq(floors.buildingId, expected.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (referencedFloor) {
|
||||
throw new Error(
|
||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind."
|
||||
);
|
||||
}
|
||||
const deleted = database
|
||||
.delete(buildings)
|
||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
||||
.run();
|
||||
if (deleted.changes !== 1) {
|
||||
throw new Error("Project building could not be deleted.");
|
||||
}
|
||||
return createProjectBuildingInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private updateBuilding(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: ProjectBuildingSnapshot,
|
||||
target: ProjectBuildingSnapshot
|
||||
) {
|
||||
const persisted = database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
||||
.get();
|
||||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project building changed before update.");
|
||||
}
|
||||
const updated = database
|
||||
.update(buildings)
|
||||
.set({ name: target.name })
|
||||
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
throw new Error("Das Gebäude konnte nicht aktualisiert werden.");
|
||||
}
|
||||
return createProjectBuildingUpdateProjectCommand(target, expected);
|
||||
}
|
||||
|
||||
private assertBuildingInProject(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
buildingId: string | null
|
||||
) {
|
||||
if (buildingId === null) {
|
||||
return;
|
||||
}
|
||||
const building = database
|
||||
.select({ id: buildings.id })
|
||||
.from(buildings)
|
||||
.where(and(eq(buildings.id, buildingId), eq(buildings.projectId, projectId)))
|
||||
.get();
|
||||
if (!building) {
|
||||
throw new Error("Project floor references a foreign building.");
|
||||
}
|
||||
}
|
||||
|
||||
private insertRoom(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
|
|
@ -217,9 +351,10 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
if (!persisted || !sameRecord(expected, persisted)) {
|
||||
throw new Error("Project floor changed before update.");
|
||||
}
|
||||
this.assertBuildingInProject(database, projectId, target.buildingId);
|
||||
const updated = database
|
||||
.update(floors)
|
||||
.set({ name: target.name })
|
||||
.set({ name: target.name, buildingId: target.buildingId })
|
||||
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
|
||||
.run();
|
||||
if (updated.changes !== 1) {
|
||||
|
|
@ -326,6 +461,24 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||
}
|
||||
}
|
||||
|
||||
function toCurrentCommandVersion(
|
||||
command: ProjectLocationStructureProjectCommand
|
||||
): ProjectLocationStructureProjectCommand {
|
||||
switch (command.type) {
|
||||
case projectFloorInsertCommandType:
|
||||
assertProjectFloorInsertProjectCommand(command);
|
||||
return upgradeProjectFloorCommand(command);
|
||||
case projectFloorDeleteCommandType:
|
||||
assertProjectFloorDeleteProjectCommand(command);
|
||||
return upgradeProjectFloorCommand(command);
|
||||
case projectFloorUpdateCommandType:
|
||||
assertProjectFloorUpdateProjectCommand(command);
|
||||
return upgradeProjectFloorCommand(command);
|
||||
default:
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecord(
|
||||
expected: object,
|
||||
actual: object
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { circuits } from "../schema/circuits.js";
|
|||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||
|
|
@ -164,6 +165,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
|
|||
}))
|
||||
),
|
||||
projectDevices: byId(state.projectDevices),
|
||||
buildings: byId(state.buildings),
|
||||
floors: byId(state.floors),
|
||||
rooms: byId(state.rooms),
|
||||
externalCsvConfiguration: state.externalCsvConfiguration,
|
||||
|
|
@ -219,6 +221,10 @@ function replaceProjectState(
|
|||
.delete(floors)
|
||||
.where(eq(floors.projectId, state.project.id))
|
||||
.run();
|
||||
database
|
||||
.delete(buildings)
|
||||
.where(eq(buildings.projectId, state.project.id))
|
||||
.run();
|
||||
|
||||
database
|
||||
.update(projects)
|
||||
|
|
@ -241,6 +247,7 @@ function replaceProjectState(
|
|||
.where(eq(projects.id, state.project.id))
|
||||
.run();
|
||||
|
||||
insertMany(database, buildings, state.buildings);
|
||||
insertMany(database, floors, state.floors);
|
||||
if (state.externalCsvConfiguration !== null) {
|
||||
database
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { circuits } from "../schema/circuits.js";
|
|||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
|
@ -219,6 +220,12 @@ export function readProjectStateSnapshot(
|
|||
.where(eq(projectDevices.projectId, projectId))
|
||||
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
|
||||
.all();
|
||||
const buildingRows = database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(eq(buildings.projectId, projectId))
|
||||
.orderBy(asc(buildings.sortOrder), asc(buildings.id))
|
||||
.all();
|
||||
const floorRows = database
|
||||
.select()
|
||||
.from(floors)
|
||||
|
|
@ -273,6 +280,7 @@ export function readProjectStateSnapshot(
|
|||
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
||||
})),
|
||||
projectDevices: projectDeviceRows,
|
||||
buildings: buildingRows,
|
||||
floors: floorRows,
|
||||
rooms: roomRows,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { circuits } from "../schema/circuits.js";
|
|||
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
|
||||
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
import { floors } from "../schema/floors.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
|
@ -127,6 +128,7 @@ function insertProjectState(
|
|||
.values(state.externalCsvConfiguration)
|
||||
.run();
|
||||
}
|
||||
insertMany(database, buildings, state.buildings);
|
||||
insertMany(database, floors, state.floors);
|
||||
insertMany(database, rooms, state.rooms);
|
||||
insertMany(database, projectDevices, state.projectDevices);
|
||||
|
|
|
|||
11
src/db/schema/buildings.ts
Normal file
11
src/db/schema/buildings.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const buildings = sqliteTable("buildings", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { buildings } from "./buildings.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const floors = sqliteTable("floors", {
|
||||
|
|
@ -8,4 +9,9 @@ export const floors = sqliteTable("floors", {
|
|||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
// Optional parent building. Floors created before buildings existed stay
|
||||
// without one and are shown as "Ohne Gebäude".
|
||||
buildingId: text("building_id").references(() => buildings.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue