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,17 +8,15 @@ import {
copyDistributionBoard,
copyProjectDeviceToGlobal,
createDistributionBoard,
createFloor,
createProjectDevice,
createRoom,
deleteFloor,
deleteProjectDevice,
deleteRoom,
deleteDistributionBoard,
disconnectProjectDeviceRows,
exportProjectTransfer,
getProject,
getProjectDeviceSyncPreview,
listBuildings,
listCircuitLists,
listDistributionBoards,
listFloors,
@ -28,12 +26,11 @@ import {
importProjectTransfer,
synchronizeProjectDeviceRows,
updateDistributionBoard,
updateFloor,
updateProjectDevice,
updateProjectSettings,
updateRoom,
} from "../../../frontend/utils/api";
import type {
BuildingDto,
CircuitListDto,
CreateRoomInput,
CreateProjectDeviceInput,
@ -67,7 +64,7 @@ import {
import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
import { RoomModal } from "../../../frontend/components/room-modal";
import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@ -89,6 +86,7 @@ export default function ProjectDetailPage() {
const [project, setProject] = useState<ProjectDto | null>(null);
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
@ -116,14 +114,9 @@ export default function ProjectDetailPage() {
remark: null,
})
);
const [floorName, setFloorName] = useState("");
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState<
"board" | "floor" | "room" | null
>(null);
const [structureModal, setStructureModal] = useState<"board" | null>(null);
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
useState(false);
const [editingProjectDevice, setEditingProjectDevice] =
@ -147,6 +140,7 @@ export default function ProjectDetailPage() {
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listBuildings(projectId),
listFloors(projectId),
listRooms(projectId),
listProjectDevices(projectId),
@ -156,6 +150,7 @@ export default function ProjectDetailPage() {
currentProject,
distributionBoards,
loadedCircuitLists,
loadedBuildings,
loadedFloors,
loadedRooms,
loadedProjectDevices,
@ -164,6 +159,7 @@ export default function ProjectDetailPage() {
setProject(currentProject);
setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists);
setBuildings(loadedBuildings);
setFloors(loadedFloors);
setRooms(loadedRooms);
setProjectDevices(loadedProjectDevices);
@ -176,6 +172,7 @@ export default function ProjectDetailPage() {
}, [projectId]);
const boardCount = useMemo(() => boards.length, [boards.length]);
const buildingCount = useMemo(() => buildings.length, [buildings.length]);
const floorCount = useMemo(() => floors.length, [floors.length]);
const roomCount = useMemo(() => rooms.length, [rooms.length]);
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
@ -260,74 +257,6 @@ export default function ProjectDetailPage() {
}
}
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !project || !floorName.trim()) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = editingFloor
? await updateFloor(
projectId,
editingFloor.id,
{ name: floorName.trim() },
project.currentRevision
)
: await createFloor(
projectId,
{ name: floorName.trim() },
project.currentRevision
);
setFloors((current) =>
editingFloor
? current.map((floor) =>
floor.id === result.floor.id ? result.floor : floor
)
: [...current, result.floor]
);
applyProjectRevision(result.history.currentRevision);
setFloorName("");
setEditingFloor(null);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handleSaveRoom(input: CreateRoomInput) {
if (!projectId || !project) return;
setIsSaving(true);
setError(null);
try {
const result = editingRoom
? await updateRoom(
projectId,
editingRoom.id,
input,
project.currentRevision
)
: await createRoom(projectId, input, project.currentRevision);
setRooms((current) =>
editingRoom
? current.map((room) =>
room.id === result.room.id ? result.room : room
)
: [...current, result.room]
);
applyProjectRevision(result.history.currentRevision);
setEditingRoom(null);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handleSaveProjectSettings(input: ProjectSettingsInput) {
if (!projectId || !project) {
return;
@ -515,65 +444,6 @@ export default function ProjectDetailPage() {
return result;
}
function openCreateFloor() {
setEditingFloor(null);
setFloorName("");
setStructureModal("floor");
}
function openEditFloor(floor: FloorDto) {
setEditingFloor(floor);
setFloorName(floor.name);
setStructureModal("floor");
}
function openCreateRoom() {
setEditingRoom(null);
setStructureModal("room");
}
function openEditRoom(room: RoomDto) {
setEditingRoom(room);
setStructureModal("room");
}
async function handleDeleteFloor() {
if (!projectId || !project || !editingFloor) return;
if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return;
setIsSaving(true);
setError(null);
try {
const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision);
setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id));
applyProjectRevision(result.history.currentRevision);
setEditingFloor(null);
setFloorName("");
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden.");
} finally {
setIsSaving(false);
}
}
async function handleDeleteRoom() {
if (!projectId || !project || !editingRoom) return;
if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return;
setIsSaving(true);
setError(null);
try {
const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision);
setRooms((current) => current.filter((room) => room.id !== editingRoom.id));
applyProjectRevision(result.history.currentRevision);
setEditingRoom(null);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyBoard() {
if (
!projectId ||
@ -902,14 +772,18 @@ export default function ProjectDetailPage() {
<div className="kpi-label">Verteilungen</div>
<div className="kpi-value">{boardCount}</div>
</a>
<a className="kpi" href="#etagen">
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Gebäude</div>
<div className="kpi-value">{buildingCount}</div>
</Link>
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Etagen</div>
<div className="kpi-value">{floorCount}</div>
</a>
<a className="kpi" href="#raeume">
</Link>
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Räume</div>
<div className="kpi-value">{roomCount}</div>
</a>
</Link>
<a className="kpi" href="#projektgeraete">
<div className="kpi-label">Projektgeräte</div>
<div className="kpi-value">{projectDeviceCount}</div>
@ -1025,88 +899,6 @@ export default function ProjectDetailPage() {
</div>
</div>
</section>
<section className="col-12 col-lg-5" id="etagen">
<div className="card shadow-sm h-100">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Etagen</span>
<span className="badge text-bg-secondary ms-2">{floorCount}</span>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={openCreateFloor}
type="button"
>
Hinzufügen
</button>
</div>
<ul className="list-group list-group-flush">
{floors.map((floor) => (
<li className="list-group-item d-flex justify-content-between align-items-center gap-2" key={floor.id}>
<span>{floor.name}</span>
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditFloor(floor)} type="button">
Bearbeiten
</button>
</li>
))}
{!floors.length ? (
<li className="list-group-item text-secondary">Noch keine Etagen vorhanden.</li>
) : null}
</ul>
</div>
</section>
<section className="col-12 col-lg-7" id="raeume">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Räume</span>
<span className="badge text-bg-secondary ms-2">{roomCount}</span>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={openCreateRoom}
type="button"
>
Hinzufügen
</button>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Raumnummer</th>
<th>Raumname</th>
<th>Etage</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.id}>
<td>{room.roomNumber}</td>
<td>{room.roomName}</td>
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditRoom(room)} type="button">
Bearbeiten
</button>
</td>
</tr>
))}
{!rooms.length ? (
<tr>
<td colSpan={4} className="text-center text-secondary py-4">
Noch keine Räume vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</section>
</div>
<section className="card shadow-sm mt-4" id="projektgeraete">
@ -1366,11 +1158,7 @@ export default function ProjectDetailPage() {
value={boardFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
<FloorSelectOptions buildings={buildings} floors={floors} />
</select>
</div>
<div className="col-12 col-md-6">
@ -1436,11 +1224,7 @@ export default function ProjectDetailPage() {
value={editingBoardFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
<FloorSelectOptions buildings={buildings} floors={floors} />
</select>
</div>
<div className="col-12 col-md-6">
@ -1572,57 +1356,6 @@ export default function ProjectDetailPage() {
</FormModal>
) : null}
{structureModal === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => {
setStructureModal(null);
setEditingFloor(null);
}}
onSubmit={handleSaveFloor}
submitDisabled={!floorName.trim()}
submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"}
title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"}
>
<label className="form-label" htmlFor="floor-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="floor-name"
onChange={(event) => setFloorName(event.target.value)}
placeholder="z. B. EG"
required
value={floorName}
/>
{editingFloor ? (
<div className="border-top mt-4 pt-3">
<p className="text-secondary small mb-2">
Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden.
</p>
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteFloor()} type="button">
Etage löschen
</button>
</div>
) : null}
</FormModal>
) : null}
{structureModal === "room" ? (
<RoomModal
floors={floors}
initialRoom={editingRoom ?? undefined}
isSaving={isSaving}
onClose={() => {
setStructureModal(null);
setEditingRoom(null);
}}
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
onSave={handleSaveRoom}
/>
) : null}
{isProjectDeviceModalOpen ? (
<ProjectDeviceModal
globalDevices={globalDevices}