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
|
|
@ -1461,3 +1461,144 @@ a.kpi:hover {
|
|||
.sidebar-collapsed .sidebar-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Gebäudestruktur (Gebäude → Etagen → Räume) ─────────────────────── */
|
||||
|
||||
.structure-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-md);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.structure-columns {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.structure-column-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.structure-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.structure-back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.45rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.structure-list {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.structure-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.structure-entry-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button.structure-entry-main {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.structure-entry-main:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
button.structure-entry-main:focus-visible {
|
||||
outline: 2px solid var(--accent-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.structure-entry.is-selected {
|
||||
background: var(--surface-selected);
|
||||
box-shadow: inset 3px 0 0 var(--color-signal);
|
||||
}
|
||||
|
||||
.structure-entry.is-selected .structure-entry-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.structure-entry.is-selected button.structure-entry-main:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.structure-entry-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.structure-entry-label {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.structure-entry-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.structure-entry-meta {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.structure-entry-actions {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.structure-room-number {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.structure-group-label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
background: var(--panel-bg-subtle);
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.structure-empty {
|
||||
color: var(--color-ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createBuilding,
|
||||
createFloor,
|
||||
createRoom,
|
||||
deleteBuilding,
|
||||
deleteFloor,
|
||||
deleteRoom,
|
||||
getProject,
|
||||
listBuildings,
|
||||
listFloors,
|
||||
listRooms,
|
||||
updateBuilding,
|
||||
updateFloor,
|
||||
updateRoom,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
BuildingDto,
|
||||
CreateRoomInput,
|
||||
FloorDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
} from "../../../../frontend/types";
|
||||
import {
|
||||
ALL_SELECTION,
|
||||
UNASSIGNED_BUILDING_LABEL,
|
||||
UNASSIGNED_FLOOR_LABEL,
|
||||
UNASSIGNED_SELECTION,
|
||||
countFloorsByBuilding,
|
||||
countRoomsByFloor,
|
||||
defaultBuildingIdForNewFloor,
|
||||
defaultFloorIdForNewRoom,
|
||||
describeRoomLocation,
|
||||
filterFloorsByBuilding,
|
||||
filterRooms,
|
||||
groupFloorsByBuilding,
|
||||
hasFloorsWithoutBuilding,
|
||||
normalizeBuildingSelection,
|
||||
normalizeFloorSelection,
|
||||
showsRoomsWithoutFloorEntry,
|
||||
type StructureSelection,
|
||||
} from "../../../../frontend/utils/building-structure";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||
|
||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||
|
||||
type StructureDialog =
|
||||
| { kind: "building"; building: BuildingDto | null }
|
||||
| { kind: "floor"; floor: FloorDto | null }
|
||||
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
|
||||
|
||||
function formatCount(count: number, singular: string, plural: string) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
||||
return (
|
||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||
left.roomName.localeCompare(right.roomName, "de")
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureColumnProps {
|
||||
addLabel: string;
|
||||
children: ReactNode;
|
||||
count: number;
|
||||
headingId: string;
|
||||
isAddDisabled: boolean;
|
||||
onAdd: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function StructureColumn({
|
||||
addLabel,
|
||||
children,
|
||||
count,
|
||||
headingId,
|
||||
isAddDisabled,
|
||||
onAdd,
|
||||
title,
|
||||
}: StructureColumnProps) {
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<h2 className="structure-column-title" id={headingId}>
|
||||
{title}
|
||||
<span className="badge text-bg-secondary ms-2">{count}</span>
|
||||
</h2>
|
||||
<button
|
||||
aria-label={addLabel}
|
||||
className="btn btn-sm btn-outline-primary structure-icon-button"
|
||||
disabled={isAddDisabled}
|
||||
onClick={onAdd}
|
||||
title={addLabel}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="list-group list-group-flush structure-list">{children}</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureEntryProps {
|
||||
actions?: ReactNode;
|
||||
hint?: string;
|
||||
isSelected?: boolean;
|
||||
label: ReactNode;
|
||||
meta?: string;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
function StructureEntry({
|
||||
actions,
|
||||
hint,
|
||||
isSelected = false,
|
||||
label,
|
||||
meta,
|
||||
onSelect,
|
||||
}: StructureEntryProps) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="structure-entry-text">
|
||||
<span className="structure-entry-label">{label}</span>
|
||||
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
|
||||
</span>
|
||||
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
isSelected
|
||||
? "list-group-item structure-entry is-selected"
|
||||
: "list-group-item structure-entry"
|
||||
}
|
||||
>
|
||||
{onSelect ? (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className="structure-entry-main"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="structure-entry-main">{content}</div>
|
||||
)}
|
||||
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface EntryActionsProps {
|
||||
deleteLabel: string;
|
||||
editLabel: string;
|
||||
isDisabled: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function EntryActions({
|
||||
deleteLabel,
|
||||
editLabel,
|
||||
isDisabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: EntryActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={editLabel}
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onEdit}
|
||||
title={editLabel}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={deleteLabel}
|
||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onDelete}
|
||||
title={deleteLabel}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildingStructurePage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||
const [buildingSelection, setBuildingSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [floorSelection, setFloorSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [dialog, setDialog] = useState<StructureDialog | null>(null);
|
||||
const [dialogName, setDialogName] = useState("");
|
||||
const [dialogBuildingId, setDialogBuildingId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const reloadStructure = useCallback(async () => {
|
||||
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
|
||||
await Promise.all([
|
||||
getProject(projectId),
|
||||
listBuildings(projectId),
|
||||
listFloors(projectId),
|
||||
listRooms(projectId),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setBuildings(loadedBuildings);
|
||||
setFloors(loadedFloors);
|
||||
setRooms(loadedRooms);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadStructure()
|
||||
.then(() => setError(null))
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Gebäudestruktur konnte nicht geladen werden."
|
||||
)
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId, reloadStructure]);
|
||||
|
||||
const effectiveBuildingSelection = normalizeBuildingSelection(
|
||||
buildingSelection,
|
||||
floors,
|
||||
buildings
|
||||
);
|
||||
const effectiveFloorSelection = normalizeFloorSelection(
|
||||
floorSelection,
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
|
||||
const visibleFloors = useMemo(
|
||||
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
|
||||
[buildings, effectiveBuildingSelection, floors]
|
||||
);
|
||||
const visibleFloorGroups = useMemo(
|
||||
() =>
|
||||
effectiveBuildingSelection === ALL_SELECTION && buildings.length
|
||||
? groupFloorsByBuilding(visibleFloors, buildings)
|
||||
: [{ buildingId: null, label: "", floors: visibleFloors }],
|
||||
[buildings, effectiveBuildingSelection, visibleFloors]
|
||||
);
|
||||
const visibleRooms = useMemo(
|
||||
() =>
|
||||
filterRooms(
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection,
|
||||
effectiveFloorSelection
|
||||
).sort(compareRooms),
|
||||
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
|
||||
);
|
||||
const roomsInBuildingSelection = useMemo(
|
||||
() =>
|
||||
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
|
||||
.length,
|
||||
[buildings, effectiveBuildingSelection, floors, rooms]
|
||||
);
|
||||
const floorCountByBuilding = useMemo(
|
||||
() => countFloorsByBuilding(floors, buildings),
|
||||
[buildings, floors]
|
||||
);
|
||||
const roomCountByFloor = useMemo(
|
||||
() => countRoomsByFloor(rooms, floors),
|
||||
[floors, rooms]
|
||||
);
|
||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
||||
rooms,
|
||||
floors,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
const isBusy = isSaving || !project;
|
||||
|
||||
function selectBuilding(selection: StructureSelection) {
|
||||
setBuildingSelection(selection);
|
||||
setFloorSelection(ALL_SELECTION);
|
||||
}
|
||||
|
||||
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||
execute: (expectedRevision: number) => Promise<TResult>,
|
||||
fallbackMessage: string
|
||||
): Promise<boolean> {
|
||||
if (!project) {
|
||||
return false;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await execute(project.currentRevision);
|
||||
setProject((current) =>
|
||||
current
|
||||
? { ...current, currentRevision: result.history.currentRevision }
|
||||
: current
|
||||
);
|
||||
await reloadStructure();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||
// Refresh the revision and lists so a stale state does not block the
|
||||
// next attempt; the command error stays visible.
|
||||
await reloadStructure().catch(() => undefined);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBuildingDialog(building: BuildingDto | null) {
|
||||
setDialogName(building?.name ?? "");
|
||||
setDialog({ kind: "building", building });
|
||||
}
|
||||
|
||||
function openFloorDialog(floor: FloorDto | null) {
|
||||
setDialogName(floor?.name ?? "");
|
||||
setDialogBuildingId(
|
||||
floor
|
||||
? floor.buildingId ?? ""
|
||||
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
|
||||
);
|
||||
setDialog({ kind: "floor", floor });
|
||||
}
|
||||
|
||||
function openRoomDialog(room: RoomDto | null) {
|
||||
setDialog({
|
||||
kind: "room",
|
||||
room,
|
||||
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "building" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingBuilding = dialog.building;
|
||||
const name = dialogName.trim();
|
||||
// An unchanged dialog is not a command; the backend rejects no-op updates.
|
||||
if (editingBuilding && editingBuilding.name === name) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingBuilding
|
||||
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
|
||||
: createBuilding(projectId, { name }, expectedRevision),
|
||||
"Gebäude konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "floor" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingFloor = dialog.floor;
|
||||
const input = {
|
||||
name: dialogName.trim(),
|
||||
buildingId: dialogBuildingId || null,
|
||||
};
|
||||
if (
|
||||
editingFloor &&
|
||||
editingFloor.name === input.name &&
|
||||
editingFloor.buildingId === input.buildingId
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingFloor
|
||||
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
|
||||
: createFloor(projectId, input, expectedRevision),
|
||||
"Etage konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRoom(input: CreateRoomInput) {
|
||||
if (dialog?.kind !== "room") {
|
||||
return;
|
||||
}
|
||||
const editingRoom = dialog.room;
|
||||
if (
|
||||
editingRoom &&
|
||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||
editingRoom.roomName === input.roomName.trim() &&
|
||||
editingRoom.floorId === (input.floorId || null)
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingRoom
|
||||
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
|
||||
: createRoom(projectId, input, expectedRevision),
|
||||
"Raum konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBuilding(building: BuildingDto) {
|
||||
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
|
||||
setError(BUILDING_HAS_FLOORS_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) =>
|
||||
deleteBuilding(projectId, building.id, expectedRevision),
|
||||
"Gebäude konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteFloor(floor: FloorDto) {
|
||||
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
|
||||
"Etage konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteRoom(room: RoomDto) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
|
||||
"Raum konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Projektübersicht"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link"
|
||||
href={`/projects/${projectId}`}
|
||||
title="Zurück zur Projektübersicht"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Gebäudestruktur</h1>
|
||||
<p className="text-secondary mb-0">
|
||||
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Gebäudestruktur wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="structure-columns">
|
||||
<StructureColumn
|
||||
addLabel="Gebäude hinzufügen"
|
||||
count={buildings.length}
|
||||
headingId="structure-buildings-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openBuildingDialog(null)}
|
||||
title="Gebäude"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||
/>
|
||||
{buildings.map((building) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Gebäude „${building.name}“ löschen`}
|
||||
editLabel={`Gebäude „${building.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteBuilding(building)}
|
||||
onEdit={() => openBuildingDialog(building)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveBuildingSelection === building.id}
|
||||
key={building.id}
|
||||
label={building.name}
|
||||
meta={formatCount(
|
||||
floorCountByBuilding.get(building.id) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
)}
|
||||
onSelect={() => selectBuilding(building.id)}
|
||||
/>
|
||||
))}
|
||||
{showUnassignedBuilding ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||
meta={formatCount(
|
||||
floorCountByBuilding.get(null) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
)}
|
||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!buildings.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
Noch keine Gebäude vorhanden.
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Etage hinzufügen"
|
||||
count={visibleFloors.length}
|
||||
headingId="structure-floors-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openFloorDialog(null)}
|
||||
title="Etagen"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||
/>
|
||||
{visibleFloorGroups.map((group) => (
|
||||
<FloorGroupEntries
|
||||
key={group.buildingId ?? "unassigned"}
|
||||
label={group.label}
|
||||
>
|
||||
{group.floors.map((floor) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Etage „${floor.name}“ löschen`}
|
||||
editLabel={`Etage „${floor.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteFloor(floor)}
|
||||
onEdit={() => openFloorDialog(floor)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveFloorSelection === floor.id}
|
||||
key={floor.id}
|
||||
label={floor.name}
|
||||
meta={formatCount(
|
||||
roomCountByFloor.get(floor.id) ?? 0,
|
||||
"Raum",
|
||||
"Räume"
|
||||
)}
|
||||
onSelect={() => setFloorSelection(floor.id)}
|
||||
/>
|
||||
))}
|
||||
</FloorGroupEntries>
|
||||
))}
|
||||
{showUnassignedFloor ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!visibleFloors.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{floors.length
|
||||
? "Keine Etagen in dieser Auswahl."
|
||||
: "Noch keine Etagen vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Raum hinzufügen"
|
||||
count={visibleRooms.length}
|
||||
headingId="structure-rooms-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openRoomDialog(null)}
|
||||
title="Räume"
|
||||
>
|
||||
{visibleRooms.map((room) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
|
||||
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteRoom(room)}
|
||||
onEdit={() => openRoomDialog(room)}
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
effectiveFloorSelection === ALL_SELECTION
|
||||
? describeRoomLocation(room, floors, buildings)
|
||||
: undefined
|
||||
}
|
||||
key={room.id}
|
||||
label={
|
||||
<>
|
||||
<span className="structure-room-number">{room.roomNumber}</span>{" "}
|
||||
{room.roomName}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!visibleRooms.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{rooms.length
|
||||
? "Keine Räume in dieser Auswahl."
|
||||
: "Noch keine Räume vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
</div>
|
||||
|
||||
{dialog?.kind === "building" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveBuilding}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
|
||||
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
|
||||
>
|
||||
<label className="form-label" htmlFor="building-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="building-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. Haus A"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "floor" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveFloor}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
|
||||
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="floor-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. EG"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-building">
|
||||
Gebäude
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="floor-building"
|
||||
onChange={(event) => setDialogBuildingId(event.target.value)}
|
||||
value={dialogBuildingId}
|
||||
>
|
||||
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
|
||||
{buildings.map((building) => (
|
||||
<option key={building.id} value={building.id}>
|
||||
{building.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "room" ? (
|
||||
<RoomModal
|
||||
buildings={buildings}
|
||||
floors={floors}
|
||||
initialRoom={dialog.room ?? undefined}
|
||||
initialValues={
|
||||
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorGroupEntries({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
<li className="list-group-item structure-group-label">
|
||||
{label}
|
||||
</li>
|
||||
) : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,21 +1,42 @@
|
|||
import crypto from "node:crypto";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const projectBuildingInsertCommandType = "project-building.insert" as const;
|
||||
export const projectBuildingDeleteCommandType = "project-building.delete" as const;
|
||||
export const projectBuildingUpdateCommandType = "project-building.update" as const;
|
||||
export const projectFloorInsertCommandType = "project-floor.insert" as const;
|
||||
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
|
||||
export const projectFloorUpdateCommandType = "project-floor.update" as const;
|
||||
export const projectRoomInsertCommandType = "project-room.insert" as const;
|
||||
export const projectRoomDeleteCommandType = "project-room.delete" as const;
|
||||
export const projectRoomUpdateCommandType = "project-room.update" as const;
|
||||
/** Room commands, building commands and legacy floor commands. */
|
||||
export const projectLocationStructureCommandSchemaVersion = 1 as const;
|
||||
/**
|
||||
* Floor commands carry `buildingId` since version 2. Version 1 floor
|
||||
* commands stay executable from persisted history: they were recorded before
|
||||
* buildings existed, so they are upgraded with `buildingId = null`.
|
||||
*/
|
||||
export const projectFloorCommandSchemaVersion = 2 as const;
|
||||
const legacyProjectFloorCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectBuildingSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ProjectFloorSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
buildingId: string | null;
|
||||
}
|
||||
|
||||
export type LegacyProjectFloorSnapshot = Omit<ProjectFloorSnapshot, "buildingId">;
|
||||
|
||||
export interface ProjectRoomSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
|
@ -24,39 +45,93 @@ export interface ProjectRoomSnapshot {
|
|||
roomName: string;
|
||||
}
|
||||
|
||||
interface ProjectBuildingStructureCommandPayload {
|
||||
building: ProjectBuildingSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectFloorStructureCommandPayload {
|
||||
floor: ProjectFloorSnapshot;
|
||||
}
|
||||
|
||||
interface LegacyProjectFloorStructureCommandPayload {
|
||||
floor: LegacyProjectFloorSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectRoomStructureCommandPayload {
|
||||
room: ProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectBuildingUpdateCommandPayload {
|
||||
expected: ProjectBuildingSnapshot;
|
||||
target: ProjectBuildingSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectFloorUpdateCommandPayload {
|
||||
expected: ProjectFloorSnapshot;
|
||||
target: ProjectFloorSnapshot;
|
||||
}
|
||||
|
||||
interface LegacyProjectFloorUpdateCommandPayload {
|
||||
expected: LegacyProjectFloorSnapshot;
|
||||
target: LegacyProjectFloorSnapshot;
|
||||
}
|
||||
|
||||
interface ProjectRoomUpdateCommandPayload {
|
||||
expected: ProjectRoomSnapshot;
|
||||
target: ProjectRoomSnapshot;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectBuildingInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectBuildingDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectBuildingUpdateCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
type: typeof projectBuildingUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorInsertProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface ProjectFloorUpdateProjectCommand
|
||||
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
|
||||
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
|
||||
schemaVersion: typeof projectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectFloorInsertProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorInsertCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectFloorDeleteProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface LegacyProjectFloorUpdateProjectCommand
|
||||
extends SerializedProjectCommand<LegacyProjectFloorUpdateCommandPayload> {
|
||||
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
|
||||
type: typeof projectFloorUpdateCommandType;
|
||||
}
|
||||
|
||||
|
|
@ -79,23 +154,46 @@ export interface ProjectRoomUpdateProjectCommand
|
|||
}
|
||||
|
||||
export type ProjectLocationStructureProjectCommand =
|
||||
| ProjectBuildingInsertProjectCommand
|
||||
| ProjectBuildingDeleteProjectCommand
|
||||
| ProjectBuildingUpdateProjectCommand
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| ProjectFloorUpdateProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorUpdateProjectCommand
|
||||
| ProjectRoomInsertProjectCommand
|
||||
| ProjectRoomDeleteProjectCommand
|
||||
| ProjectRoomUpdateProjectCommand;
|
||||
|
||||
export function createProjectFloorSnapshot(
|
||||
export function createProjectBuildingSnapshot(
|
||||
projectId: string,
|
||||
name: string,
|
||||
sortOrder: number
|
||||
): ProjectBuildingSnapshot {
|
||||
const building: ProjectBuildingSnapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
sortOrder,
|
||||
};
|
||||
assertProjectBuildingSnapshot(building);
|
||||
return building;
|
||||
}
|
||||
|
||||
export function createProjectFloorSnapshot(
|
||||
projectId: string,
|
||||
name: string,
|
||||
sortOrder: number,
|
||||
buildingId: string | null = null
|
||||
): ProjectFloorSnapshot {
|
||||
const floor: ProjectFloorSnapshot = {
|
||||
id: crypto.randomUUID(),
|
||||
projectId,
|
||||
name: name.trim(),
|
||||
sortOrder,
|
||||
buildingId: buildingId?.trim() || null,
|
||||
};
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return floor;
|
||||
|
|
@ -120,12 +218,46 @@ export function createProjectRoomSnapshot(
|
|||
return room;
|
||||
}
|
||||
|
||||
export function createProjectBuildingInsertProjectCommand(
|
||||
building: ProjectBuildingSnapshot
|
||||
): ProjectBuildingInsertProjectCommand {
|
||||
assertProjectBuildingSnapshot(building);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectBuildingInsertCommandType,
|
||||
payload: { building },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectBuildingDeleteProjectCommand(
|
||||
building: ProjectBuildingSnapshot
|
||||
): ProjectBuildingDeleteProjectCommand {
|
||||
assertProjectBuildingSnapshot(building);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectBuildingDeleteCommandType,
|
||||
payload: { building },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectBuildingUpdateProjectCommand(
|
||||
expected: ProjectBuildingSnapshot,
|
||||
target: ProjectBuildingSnapshot
|
||||
): ProjectBuildingUpdateProjectCommand {
|
||||
assertMatchingBuildingUpdate(expected, target);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
type: projectBuildingUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectFloorInsertProjectCommand(
|
||||
floor: ProjectFloorSnapshot
|
||||
): ProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectFloorCommandSchemaVersion,
|
||||
type: projectFloorInsertCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
|
|
@ -136,7 +268,7 @@ export function createProjectFloorDeleteProjectCommand(
|
|||
): ProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorSnapshot(floor);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectFloorCommandSchemaVersion,
|
||||
type: projectFloorDeleteCommandType,
|
||||
payload: { floor },
|
||||
};
|
||||
|
|
@ -170,7 +302,7 @@ export function createProjectFloorUpdateProjectCommand(
|
|||
): ProjectFloorUpdateProjectCommand {
|
||||
assertMatchingFloorUpdate(expected, target);
|
||||
return {
|
||||
schemaVersion: projectLocationStructureCommandSchemaVersion,
|
||||
schemaVersion: projectFloorCommandSchemaVersion,
|
||||
type: projectFloorUpdateCommandType,
|
||||
payload: { expected, target },
|
||||
};
|
||||
|
|
@ -188,9 +320,43 @@ export function createProjectRoomUpdateProjectCommand(
|
|||
};
|
||||
}
|
||||
|
||||
export function assertProjectBuildingInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectBuildingInsertProjectCommand {
|
||||
assertProjectBuildingStructureProjectCommand(
|
||||
command,
|
||||
projectBuildingInsertCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectBuildingDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectBuildingDeleteProjectCommand {
|
||||
assertProjectBuildingStructureProjectCommand(
|
||||
command,
|
||||
projectBuildingDeleteCommandType
|
||||
);
|
||||
}
|
||||
|
||||
export function assertProjectBuildingUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectBuildingUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== projectBuildingUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported project-building update command.");
|
||||
}
|
||||
assertMatchingBuildingUpdate(command.payload.expected, command.payload.target);
|
||||
}
|
||||
|
||||
export function assertProjectFloorInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorInsertProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorInsertCommandType
|
||||
|
|
@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand(
|
|||
|
||||
export function assertProjectFloorDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorDeleteProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand {
|
||||
assertProjectFloorStructureProjectCommand(
|
||||
command,
|
||||
projectFloorDeleteCommandType
|
||||
|
|
@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand(
|
|||
|
||||
export function assertProjectFloorUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ProjectFloorUpdateProjectCommand {
|
||||
): asserts command is
|
||||
| ProjectFloorUpdateProjectCommand
|
||||
| LegacyProjectFloorUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
command.type !== projectFloorUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 2
|
||||
) {
|
||||
throw new Error("Unsupported project-floor update command.");
|
||||
}
|
||||
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
|
||||
assertMatchingFloorUpdate(
|
||||
upgradeLegacyFloorSnapshot(command.payload.expected),
|
||||
upgradeLegacyFloorSnapshot(command.payload.target)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
|
||||
throw new Error("Unsupported project-floor update command.");
|
||||
}
|
||||
assertMatchingFloorUpdate(command.payload.expected, command.payload.target);
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +431,68 @@ export function assertProjectRoomUpdateProjectCommand(
|
|||
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades a (validated) floor command of any supported version to the
|
||||
* current version. Version 1 commands predate buildings; their floors had no
|
||||
* building, so `buildingId` becomes null.
|
||||
*/
|
||||
export function upgradeProjectFloorCommand(
|
||||
command:
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand
|
||||
): ProjectFloorInsertProjectCommand;
|
||||
export function upgradeProjectFloorCommand(
|
||||
command:
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand
|
||||
): ProjectFloorDeleteProjectCommand;
|
||||
export function upgradeProjectFloorCommand(
|
||||
command:
|
||||
| ProjectFloorUpdateProjectCommand
|
||||
| LegacyProjectFloorUpdateProjectCommand
|
||||
): ProjectFloorUpdateProjectCommand;
|
||||
export function upgradeProjectFloorCommand(
|
||||
command:
|
||||
| ProjectFloorInsertProjectCommand
|
||||
| ProjectFloorDeleteProjectCommand
|
||||
| ProjectFloorUpdateProjectCommand
|
||||
| LegacyProjectFloorInsertProjectCommand
|
||||
| LegacyProjectFloorDeleteProjectCommand
|
||||
| LegacyProjectFloorUpdateProjectCommand
|
||||
) {
|
||||
if (command.schemaVersion === projectFloorCommandSchemaVersion) {
|
||||
return command;
|
||||
}
|
||||
switch (command.type) {
|
||||
case projectFloorInsertCommandType:
|
||||
return createProjectFloorInsertProjectCommand(
|
||||
upgradeLegacyFloorSnapshot(command.payload.floor)
|
||||
);
|
||||
case projectFloorDeleteCommandType:
|
||||
return createProjectFloorDeleteProjectCommand(
|
||||
upgradeLegacyFloorSnapshot(command.payload.floor)
|
||||
);
|
||||
case projectFloorUpdateCommandType:
|
||||
return createProjectFloorUpdateProjectCommand(
|
||||
upgradeLegacyFloorSnapshot(command.payload.expected),
|
||||
upgradeLegacyFloorSnapshot(command.payload.target)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
|
||||
assertProjectBuildingSnapshot(expected);
|
||||
assertProjectBuildingSnapshot(target);
|
||||
if (
|
||||
expected.id !== target.id ||
|
||||
expected.projectId !== target.projectId ||
|
||||
expected.sortOrder !== target.sortOrder ||
|
||||
expected.name === target.name
|
||||
) {
|
||||
throw new Error("Project-building update must change only the name.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
|
||||
assertProjectFloorSnapshot(expected);
|
||||
assertProjectFloorSnapshot(target);
|
||||
|
|
@ -259,9 +500,11 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
|
|||
expected.id !== target.id ||
|
||||
expected.projectId !== target.projectId ||
|
||||
expected.sortOrder !== target.sortOrder ||
|
||||
expected.name === target.name
|
||||
(expected.name === target.name && expected.buildingId === target.buildingId)
|
||||
) {
|
||||
throw new Error("Project-floor update must change only the name.");
|
||||
throw new Error(
|
||||
"Project-floor update must change only the name or the building."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -277,22 +520,49 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) {
|
|||
}
|
||||
}
|
||||
|
||||
export function assertProjectBuildingSnapshot(
|
||||
building: unknown
|
||||
): asserts building is ProjectBuildingSnapshot {
|
||||
if (!isPlainObject(building) || Object.keys(building).length !== 4) {
|
||||
throw new Error("Project-building snapshot is invalid.");
|
||||
}
|
||||
assertNormalizedNonEmptyString(building.id, "building.id");
|
||||
assertNormalizedNonEmptyString(building.projectId, "building.projectId");
|
||||
assertNormalizedNonEmptyString(building.name, "building.name");
|
||||
assertSortOrder(building.sortOrder, "building.sortOrder");
|
||||
}
|
||||
|
||||
export function assertProjectFloorSnapshot(
|
||||
floor: unknown
|
||||
): asserts floor is ProjectFloorSnapshot {
|
||||
if (!isPlainObject(floor) || Object.keys(floor).length !== 5) {
|
||||
throw new Error("Project-floor snapshot is invalid.");
|
||||
}
|
||||
assertLegacyFloorFields(floor);
|
||||
if (floor.buildingId !== null) {
|
||||
assertNormalizedNonEmptyString(floor.buildingId, "floor.buildingId");
|
||||
}
|
||||
}
|
||||
|
||||
function assertLegacyProjectFloorSnapshot(
|
||||
floor: unknown
|
||||
): asserts floor is LegacyProjectFloorSnapshot {
|
||||
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
|
||||
throw new Error("Project-floor snapshot is invalid.");
|
||||
}
|
||||
assertLegacyFloorFields(floor);
|
||||
}
|
||||
|
||||
function assertLegacyFloorFields(floor: Record<string, unknown>) {
|
||||
assertNormalizedNonEmptyString(floor.id, "floor.id");
|
||||
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
|
||||
assertNormalizedNonEmptyString(floor.name, "floor.name");
|
||||
if (
|
||||
typeof floor.sortOrder !== "number" ||
|
||||
!Number.isSafeInteger(floor.sortOrder) ||
|
||||
floor.sortOrder < 0
|
||||
) {
|
||||
throw new Error("floor.sortOrder must be a non-negative integer.");
|
||||
}
|
||||
assertSortOrder(floor.sortOrder, "floor.sortOrder");
|
||||
}
|
||||
|
||||
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
|
||||
assertLegacyProjectFloorSnapshot(floor);
|
||||
return { ...floor, buildingId: null };
|
||||
}
|
||||
|
||||
export function assertProjectRoomSnapshot(
|
||||
|
|
@ -310,11 +580,11 @@ export function assertProjectRoomSnapshot(
|
|||
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
|
||||
}
|
||||
|
||||
function assertProjectFloorStructureProjectCommand(
|
||||
function assertProjectBuildingStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectFloorInsertCommandType
|
||||
| typeof projectFloorDeleteCommandType
|
||||
| typeof projectBuildingInsertCommandType
|
||||
| typeof projectBuildingDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
|
||||
|
|
@ -322,6 +592,29 @@ function assertProjectFloorStructureProjectCommand(
|
|||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-building structure command.");
|
||||
}
|
||||
assertProjectBuildingSnapshot(command.payload.building);
|
||||
}
|
||||
|
||||
function assertProjectFloorStructureProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
expectedType:
|
||||
| typeof projectFloorInsertCommandType
|
||||
| typeof projectFloorDeleteCommandType
|
||||
) {
|
||||
if (
|
||||
command.type !== expectedType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 1
|
||||
) {
|
||||
throw new Error("Unsupported project-floor structure command.");
|
||||
}
|
||||
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
|
||||
assertLegacyProjectFloorSnapshot(command.payload.floor);
|
||||
return;
|
||||
}
|
||||
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
|
||||
throw new Error("Unsupported project-floor structure command.");
|
||||
}
|
||||
assertProjectFloorSnapshot(command.payload.floor);
|
||||
|
|
@ -344,6 +637,16 @@ function assertProjectRoomStructureProjectCommand(
|
|||
assertProjectRoomSnapshot(command.payload.room);
|
||||
}
|
||||
|
||||
function assertSortOrder(value: unknown, field: string) {
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < 0
|
||||
) {
|
||||
throw new Error(`${field} must be a non-negative integer.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNormalizedNonEmptyString(
|
||||
value: unknown,
|
||||
field: string
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ import {
|
|||
type ExternalModelStateSnapshot,
|
||||
} from "../../external-model/domain/external-model-contracts.js";
|
||||
|
||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||
export const projectStateSnapshotSchemaVersion = 7 as const;
|
||||
const coverProjectStateSnapshotSchemaVersion = 6 as const;
|
||||
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
|
||||
const previousProjectStateSnapshotSchemaVersion = 4 as const;
|
||||
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
|
||||
|
|
@ -237,7 +238,7 @@ const projectDeviceSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const floorSchema = z
|
||||
const buildingSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
|
|
@ -246,6 +247,19 @@ const floorSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const legacyFloorSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: z.string().trim().min(1),
|
||||
sortOrder: finiteNumberSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const floorSchema = legacyFloorSchema
|
||||
.extend({ buildingId: idSchema.nullable() })
|
||||
.strict();
|
||||
|
||||
const roomSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
|
|
@ -490,11 +504,24 @@ export function parseExternalModelStateSnapshot(
|
|||
return externalModelStateSchema.parse(value);
|
||||
}
|
||||
|
||||
const projectStateSnapshotContents = {
|
||||
// Versions 1-6 have floors without a building and no building list.
|
||||
const previousProjectStateSnapshotContents = {
|
||||
circuitLists: z.array(circuitListSchema),
|
||||
circuitSections: z.array(circuitSectionSchema),
|
||||
circuits: z.array(circuitSchema),
|
||||
projectDevices: z.array(projectDeviceSchema),
|
||||
floors: z.array(legacyFloorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
|
||||
distributionBoardComponentProtectionDevices: z.array(
|
||||
componentProtectionDeviceSchema
|
||||
),
|
||||
};
|
||||
|
||||
const projectStateSnapshotContents = {
|
||||
...previousProjectStateSnapshotContents,
|
||||
buildings: z.array(buildingSchema),
|
||||
floors: z.array(floorSchema),
|
||||
rooms: z.array(roomSchema),
|
||||
distributionBoardComponents: z.array(distributionBoardComponentSchema),
|
||||
|
|
@ -515,6 +542,17 @@ export const projectStateSnapshotSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const coverProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
distributionBoards: z.array(distributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const boardInfoProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
|
||||
|
|
@ -522,7 +560,7 @@ const boardInfoProjectStateSnapshotSchema = z
|
|||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -533,7 +571,7 @@ const previousProjectStateSnapshotSchema = z
|
|||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -543,7 +581,7 @@ const externalModelProjectStateSnapshotSchema = z
|
|||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -552,7 +590,7 @@ const legacyProjectStateSnapshotSchema = z
|
|||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -561,7 +599,7 @@ const baselineProjectStateSnapshotSchema = z
|
|||
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
||||
project: previousProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
...previousProjectStateSnapshotContents,
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
@ -595,6 +633,28 @@ export function parseProjectStateSnapshot(
|
|||
}
|
||||
|
||||
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||
const cover = upgradeToCoverProjectStateSnapshot(value);
|
||||
if (
|
||||
cover === null ||
|
||||
typeof cover !== "object" ||
|
||||
Array.isArray(cover) ||
|
||||
(cover as { schemaVersion?: unknown }).schemaVersion !==
|
||||
coverProjectStateSnapshotSchemaVersion
|
||||
) {
|
||||
return cover;
|
||||
}
|
||||
// Every supported version before 7 lacks buildings; its floors had no
|
||||
// building.
|
||||
const previous = coverProjectStateSnapshotSchema.parse(cover);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
buildings: [],
|
||||
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
|
||||
};
|
||||
}
|
||||
|
||||
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
|
|
@ -611,7 +671,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
|
|
@ -622,7 +682,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const previous = previousProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
|
|
@ -633,7 +693,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const previous = externalModelProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
|
|
@ -645,7 +705,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const legacy = legacyProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...legacy,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: { ...legacy.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
legacy.distributionBoards
|
||||
|
|
@ -658,7 +718,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
const baseline = baselineProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...baseline,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
schemaVersion: coverProjectStateSnapshotSchemaVersion,
|
||||
project: {
|
||||
...baseline.project,
|
||||
isPublicBuilding: false,
|
||||
|
|
@ -765,6 +825,7 @@ function assertProjectStateSnapshotRelations(
|
|||
snapshot.projectDevices,
|
||||
"project device"
|
||||
);
|
||||
const buildingIds = uniqueIds(snapshot.buildings, "building");
|
||||
const floorIds = uniqueIds(snapshot.floors, "floor");
|
||||
const roomIds = uniqueIds(snapshot.rooms, "room");
|
||||
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
|
||||
|
|
@ -843,8 +904,14 @@ function assertProjectStateSnapshotRelations(
|
|||
);
|
||||
}
|
||||
}
|
||||
for (const building of snapshot.buildings) {
|
||||
assertProjectOwnership(building.projectId, projectId, "building");
|
||||
}
|
||||
for (const floor of snapshot.floors) {
|
||||
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||
if (floor.buildingId !== null) {
|
||||
assertReference(buildingIds, floor.buildingId, "floor building");
|
||||
}
|
||||
}
|
||||
for (const room of snapshot.rooms) {
|
||||
assertProjectOwnership(room.projectId, projectId, "room");
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export function remapProjectState(
|
|||
createId
|
||||
);
|
||||
const deviceIds = idMap(source.projectDevices, createId);
|
||||
const buildingIds = idMap(source.buildings, createId);
|
||||
const floorIds = idMap(source.floors, createId);
|
||||
const roomIds = idMap(source.rooms, createId);
|
||||
const rowIds = new Map(
|
||||
|
|
@ -242,10 +243,19 @@ export function remapProjectState(
|
|||
id: requiredId(deviceIds, device.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
buildings: source.buildings.map((building) => ({
|
||||
...building,
|
||||
id: requiredId(buildingIds, building.id),
|
||||
projectId: targetProjectId,
|
||||
})),
|
||||
floors: source.floors.map((floor) => ({
|
||||
...floor,
|
||||
id: requiredId(floorIds, floor.id),
|
||||
projectId: targetProjectId,
|
||||
buildingId:
|
||||
floor.buildingId === null
|
||||
? null
|
||||
: requiredId(buildingIds, floor.buildingId),
|
||||
})),
|
||||
rooms: source.rooms.map((room) => ({
|
||||
...room,
|
||||
|
|
|
|||
|
|
@ -70,12 +70,18 @@ import {
|
|||
type SerializedProjectCommand,
|
||||
} from "../models/project-command.model.js";
|
||||
import {
|
||||
assertProjectBuildingDeleteProjectCommand,
|
||||
assertProjectBuildingInsertProjectCommand,
|
||||
assertProjectBuildingUpdateProjectCommand,
|
||||
assertProjectFloorDeleteProjectCommand,
|
||||
assertProjectFloorInsertProjectCommand,
|
||||
assertProjectFloorUpdateProjectCommand,
|
||||
assertProjectRoomDeleteProjectCommand,
|
||||
assertProjectRoomInsertProjectCommand,
|
||||
assertProjectRoomUpdateProjectCommand,
|
||||
projectBuildingDeleteCommandType,
|
||||
projectBuildingInsertCommandType,
|
||||
projectBuildingUpdateCommandType,
|
||||
projectFloorDeleteCommandType,
|
||||
projectFloorInsertCommandType,
|
||||
projectFloorUpdateCommandType,
|
||||
|
|
@ -492,6 +498,18 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectBuildingInsertCommandType: {
|
||||
assertProjectBuildingInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
|
||||
}
|
||||
case projectBuildingDeleteCommandType: {
|
||||
assertProjectBuildingDeleteProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
|
||||
}
|
||||
case projectBuildingUpdateCommandType: {
|
||||
assertProjectBuildingUpdateProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
|
|
|
|||
|
|
@ -7,14 +7,21 @@ import { listProjects } from "../utils/api";
|
|||
import type { ProjectDto } from "../types";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
// Anchors on the project overview page (/projects/[id]).
|
||||
const PROJECT_SECTIONS = [
|
||||
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
||||
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
||||
{ id: "etagen", label: "Etagen", icon: "▥" },
|
||||
{ id: "raeume", label: "Räume", icon: "⌂" },
|
||||
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
||||
] as const;
|
||||
|
||||
// Separate project sub-routes (/projects/[id]/<segment>).
|
||||
const PROJECT_ROUTES = [
|
||||
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
||||
] as const;
|
||||
|
||||
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
||||
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
|
|
@ -22,8 +29,14 @@ interface SidebarProps {
|
|||
|
||||
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
|
||||
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
|
||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||
const projectSubPath = projectMatch?.[2] ?? "";
|
||||
// Section anchors only exist (and are observed) on the overview itself.
|
||||
const overviewProjectId =
|
||||
activeProjectId && (projectSubPath === "" || projectSubPath === "/")
|
||||
? activeProjectId
|
||||
: null;
|
||||
|
||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
|
|
@ -42,7 +55,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
if (!overviewProjectId) {
|
||||
setActiveSection(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -69,7 +82,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
|
||||
elements.forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, [activeProjectId]);
|
||||
}, [overviewProjectId]);
|
||||
|
||||
return (
|
||||
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
|
||||
|
|
@ -122,17 +135,46 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
|||
{activeProjectId && (
|
||||
<>
|
||||
<div className="sidebar-section-label">In diesem Projekt</div>
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</a>
|
||||
))}
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
|
||||
overviewProjectId ? (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={id}
|
||||
href={`/projects/${activeProjectId}#${id}`}
|
||||
className="sidebar-link"
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{PROJECT_ROUTES.map(({ segment, label, icon }) => {
|
||||
const href = `/projects/${activeProjectId}/${segment}`;
|
||||
const isActive =
|
||||
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
|
||||
return (
|
||||
<Link
|
||||
key={segment}
|
||||
href={href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={isActive ? "sidebar-link active" : "sidebar-link"}
|
||||
title={label}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
<span className="sidebar-label">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
|
|
|||
49
src/frontend/components/floor-select-options.tsx
Normal file
49
src/frontend/components/floor-select-options.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { BuildingDto, FloorDto } from "../types";
|
||||
import { groupFloorsByBuilding } from "../utils/building-structure";
|
||||
|
||||
export type FloorOption = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
|
||||
interface FloorSelectOptionsProps {
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor <option>s for a <select>, grouped by building via <optgroup> as soon
|
||||
* as the project has buildings. The caller renders its own empty option
|
||||
* ("Ohne Etage") before these.
|
||||
*/
|
||||
export function FloorSelectOptions({
|
||||
buildings = [],
|
||||
floors,
|
||||
}: FloorSelectOptionsProps) {
|
||||
if (!buildings.length) {
|
||||
return (
|
||||
<>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groupFloorsByBuilding(floors, buildings).map((group) => (
|
||||
<optgroup key={group.buildingId ?? "unassigned"} label={group.label}>
|
||||
{group.floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { type FormEvent, useState } from "react";
|
||||
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
|
||||
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
|
||||
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface RoomModalProps {
|
||||
floors: FloorDto[];
|
||||
/** When given, the floor select groups floors by building. */
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
initialRoom?: RoomDto;
|
||||
initialValues?: Partial<CreateRoomInput>;
|
||||
isSaving: boolean;
|
||||
|
|
@ -15,6 +18,7 @@ interface RoomModalProps {
|
|||
}
|
||||
|
||||
export function RoomModal({
|
||||
buildings,
|
||||
floors,
|
||||
initialRoom,
|
||||
initialValues,
|
||||
|
|
@ -83,7 +87,7 @@ export function RoomModal({
|
|||
value={floorId}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -226,11 +226,24 @@ export interface CircuitListDto {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface BuildingDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface ProjectBuildingCommandResultDto
|
||||
extends ProjectCommandResultDto {
|
||||
building: BuildingDto;
|
||||
}
|
||||
|
||||
export interface FloorDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
buildingId: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectFloorCommandResultDto
|
||||
|
|
@ -288,8 +301,14 @@ export interface ProjectDeviceCommandResultDto
|
|||
projectDevice: ProjectDeviceDto;
|
||||
}
|
||||
|
||||
export interface CreateBuildingInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CreateFloorInput {
|
||||
name: string;
|
||||
/** Omitted = keep the current assignment on update; null = no building. */
|
||||
buildingId?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateRoomInput {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type {
|
||||
BuildingDto,
|
||||
CircuitListDto,
|
||||
CreateBuildingInput,
|
||||
CreateFloorInput,
|
||||
CreateProjectDeviceInput,
|
||||
CreateRoomInput,
|
||||
|
|
@ -17,6 +19,7 @@ import type {
|
|||
ProjectCommandResultDto,
|
||||
ProjectDeviceCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectBuildingCommandResultDto,
|
||||
ProjectFloorCommandResultDto,
|
||||
ProjectHistoryStateDto,
|
||||
ProjectRevisionPageDto,
|
||||
|
|
@ -881,6 +884,61 @@ export function renumberCircuitSectionCommand(
|
|||
);
|
||||
}
|
||||
|
||||
export function listBuildings(projectId: string) {
|
||||
return request<BuildingDto[]>(`/api/projects/${projectId}/buildings`);
|
||||
}
|
||||
|
||||
export function createBuilding(
|
||||
projectId: string,
|
||||
input: CreateBuildingInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectBuildingCommandResultDto>(
|
||||
`/api/projects/${projectId}/buildings`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: input.name, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function updateBuilding(
|
||||
projectId: string,
|
||||
buildingId: string,
|
||||
input: CreateBuildingInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectBuildingCommandResultDto>(
|
||||
`/api/projects/${projectId}/buildings/${buildingId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ name: input.name, expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteBuilding(
|
||||
projectId: string,
|
||||
buildingId: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectCommandResultDto>(
|
||||
`/api/projects/${projectId}/buildings/${buildingId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// buildingId is only sent when the caller passes it (string or null);
|
||||
// an omitted buildingId keeps the current assignment on update.
|
||||
function toFloorRequestBody(input: CreateFloorInput, expectedRevision: number) {
|
||||
return input.buildingId === undefined
|
||||
? { name: input.name, expectedRevision }
|
||||
: { name: input.name, buildingId: input.buildingId, expectedRevision };
|
||||
}
|
||||
|
||||
export function listFloors(projectId: string) {
|
||||
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
||||
}
|
||||
|
|
@ -894,7 +952,7 @@ export function createFloor(
|
|||
`/api/projects/${projectId}/floors`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
body: JSON.stringify(toFloorRequestBody(input, expectedRevision)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
@ -907,7 +965,7 @@ export function updateFloor(
|
|||
) {
|
||||
return request<ProjectFloorCommandResultDto>(`/api/projects/${projectId}/floors/${floorId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
body: JSON.stringify(toFloorRequestBody(input, expectedRevision)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
284
src/frontend/utils/building-structure.ts
Normal file
284
src/frontend/utils/building-structure.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// Pure selection, filtering and grouping rules for the building structure
|
||||
// view (Gebäude → Etagen → Räume) and grouped floor selects.
|
||||
import type { BuildingDto, FloorDto, RoomDto } from "../types";
|
||||
|
||||
/** Selection key for "Alle" in the building and floor columns. */
|
||||
export const ALL_SELECTION = "__all__";
|
||||
/** Selection key for "Ohne Gebäude" / "Ohne Etage". */
|
||||
export const UNASSIGNED_SELECTION = "__unassigned__";
|
||||
|
||||
/** ALL_SELECTION, UNASSIGNED_SELECTION or a building/floor id. */
|
||||
export type StructureSelection = string;
|
||||
|
||||
export const UNASSIGNED_BUILDING_LABEL = "Ohne Gebäude";
|
||||
export const UNASSIGNED_FLOOR_LABEL = "Ohne Etage";
|
||||
|
||||
type BuildingLike = Pick<BuildingDto, "id" | "name">;
|
||||
type FloorLike = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
type RoomLike = Pick<RoomDto, "floorId">;
|
||||
|
||||
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
|
||||
/** null for floors without (known) building. */
|
||||
buildingId: string | null;
|
||||
label: string;
|
||||
floors: TFloor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The building a floor belongs to, or null when it has none or references a
|
||||
* building that is not (or no longer) loaded.
|
||||
*/
|
||||
export function resolveFloorBuildingId(
|
||||
floor: FloorLike,
|
||||
buildingIds: ReadonlySet<string>
|
||||
): string | null {
|
||||
const buildingId = floor.buildingId ?? null;
|
||||
return buildingId !== null && buildingIds.has(buildingId)
|
||||
? buildingId
|
||||
: null;
|
||||
}
|
||||
|
||||
function toIdSet(entries: ReadonlyArray<{ id: string }>) {
|
||||
return new Set(entries.map((entry) => entry.id));
|
||||
}
|
||||
|
||||
export function hasFloorsWithoutBuilding(
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): boolean {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
return floors.some(
|
||||
(floor) => resolveFloorBuildingId(floor, buildingIds) === null
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRoomsWithoutFloor(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>
|
||||
): boolean {
|
||||
const floorIds = toIdSet(floors);
|
||||
return rooms.some(
|
||||
(room) => room.floorId === null || !floorIds.has(room.floorId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups floors by building in building order; floors without a known
|
||||
* building come last under "Ohne Gebäude". Empty groups are omitted and the
|
||||
* floor order inside a group is preserved.
|
||||
*/
|
||||
export function groupFloorsByBuilding<TFloor extends FloorLike>(
|
||||
floors: ReadonlyArray<TFloor>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): FloorGroup<TFloor>[] {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const floorsByBuilding = new Map<string | null, TFloor[]>();
|
||||
for (const floor of floors) {
|
||||
const buildingId = resolveFloorBuildingId(floor, buildingIds);
|
||||
const group = floorsByBuilding.get(buildingId) ?? [];
|
||||
group.push(floor);
|
||||
floorsByBuilding.set(buildingId, group);
|
||||
}
|
||||
const groups: FloorGroup<TFloor>[] = [];
|
||||
for (const building of buildings) {
|
||||
const groupFloors = floorsByBuilding.get(building.id);
|
||||
if (groupFloors?.length) {
|
||||
groups.push({
|
||||
buildingId: building.id,
|
||||
label: building.name,
|
||||
floors: groupFloors,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unassigned = floorsByBuilding.get(null);
|
||||
if (unassigned?.length) {
|
||||
groups.push({
|
||||
buildingId: null,
|
||||
label: UNASSIGNED_BUILDING_LABEL,
|
||||
floors: unassigned,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Floors shown in the floor column for a building selection. */
|
||||
export function filterFloorsByBuilding<TFloor extends FloorLike>(
|
||||
floors: ReadonlyArray<TFloor>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
buildingSelection: StructureSelection
|
||||
): TFloor[] {
|
||||
if (buildingSelection === ALL_SELECTION) {
|
||||
return [...floors];
|
||||
}
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const targetBuildingId =
|
||||
buildingSelection === UNASSIGNED_SELECTION ? null : buildingSelection;
|
||||
return floors.filter(
|
||||
(floor) => resolveFloorBuildingId(floor, buildingIds) === targetBuildingId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ohne Etage" is offered only when rooms without floor exist and the
|
||||
* building selection can contain them ("Alle" or "Ohne Gebäude"), because a
|
||||
* room without floor has no building either.
|
||||
*/
|
||||
export function showsRoomsWithoutFloorEntry(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildingSelection: StructureSelection
|
||||
): boolean {
|
||||
return (
|
||||
(buildingSelection === ALL_SELECTION ||
|
||||
buildingSelection === UNASSIGNED_SELECTION) &&
|
||||
hasRoomsWithoutFloor(rooms, floors)
|
||||
);
|
||||
}
|
||||
|
||||
/** Rooms shown in the room column for the current selections. */
|
||||
export function filterRooms<TRoom extends RoomLike>(
|
||||
rooms: ReadonlyArray<TRoom>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
buildingSelection: StructureSelection,
|
||||
floorSelection: StructureSelection
|
||||
): TRoom[] {
|
||||
const floorIds = toIdSet(floors);
|
||||
const isWithoutFloor = (room: RoomLike) =>
|
||||
room.floorId === null || !floorIds.has(room.floorId);
|
||||
|
||||
if (floorSelection === UNASSIGNED_SELECTION) {
|
||||
return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection)
|
||||
? rooms.filter(isWithoutFloor)
|
||||
: [];
|
||||
}
|
||||
if (floorSelection !== ALL_SELECTION) {
|
||||
return rooms.filter((room) => room.floorId === floorSelection);
|
||||
}
|
||||
if (buildingSelection === ALL_SELECTION) {
|
||||
return [...rooms];
|
||||
}
|
||||
const visibleFloorIds = toIdSet(
|
||||
filterFloorsByBuilding(floors, buildings, buildingSelection)
|
||||
);
|
||||
return rooms.filter(
|
||||
(room) =>
|
||||
(room.floorId !== null && visibleFloorIds.has(room.floorId)) ||
|
||||
(buildingSelection === UNASSIGNED_SELECTION && isWithoutFloor(room))
|
||||
);
|
||||
}
|
||||
|
||||
/** Falls back to "Alle" when a selected building vanished. */
|
||||
export function normalizeBuildingSelection(
|
||||
selection: StructureSelection,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): StructureSelection {
|
||||
if (selection === ALL_SELECTION) {
|
||||
return selection;
|
||||
}
|
||||
if (selection === UNASSIGNED_SELECTION) {
|
||||
return hasFloorsWithoutBuilding(floors, buildings)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
return buildings.some((building) => building.id === selection)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
|
||||
/** Falls back to "Alle" when a selected floor is no longer visible. */
|
||||
export function normalizeFloorSelection(
|
||||
selection: StructureSelection,
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
buildingSelection: StructureSelection
|
||||
): StructureSelection {
|
||||
if (selection === ALL_SELECTION) {
|
||||
return selection;
|
||||
}
|
||||
if (selection === UNASSIGNED_SELECTION) {
|
||||
return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
return filterFloorsByBuilding(floors, buildings, buildingSelection).some(
|
||||
(floor) => floor.id === selection
|
||||
)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
|
||||
/** Building preselected in the floor dialog ("" = Ohne Gebäude). */
|
||||
export function defaultBuildingIdForNewFloor(
|
||||
buildingSelection: StructureSelection
|
||||
): string {
|
||||
return buildingSelection === ALL_SELECTION ||
|
||||
buildingSelection === UNASSIGNED_SELECTION
|
||||
? ""
|
||||
: buildingSelection;
|
||||
}
|
||||
|
||||
/** Floor preselected in the room dialog ("" = Ohne Etage). */
|
||||
export function defaultFloorIdForNewRoom(
|
||||
floorSelection: StructureSelection
|
||||
): string {
|
||||
return floorSelection === ALL_SELECTION ||
|
||||
floorSelection === UNASSIGNED_SELECTION
|
||||
? ""
|
||||
: floorSelection;
|
||||
}
|
||||
|
||||
export function countFloorsByBuilding(
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): Map<string | null, number> {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const counts = new Map<string | null, number>();
|
||||
for (const floor of floors) {
|
||||
const buildingId = resolveFloorBuildingId(floor, buildingIds);
|
||||
counts.set(buildingId, (counts.get(buildingId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Room counts per floor id; rooms without known floor count under null. */
|
||||
export function countRoomsByFloor(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>
|
||||
): Map<string | null, number> {
|
||||
const floorIds = toIdSet(floors);
|
||||
const counts = new Map<string | null, number>();
|
||||
for (const room of rooms) {
|
||||
const floorId =
|
||||
room.floorId !== null && floorIds.has(room.floorId)
|
||||
? room.floorId
|
||||
: null;
|
||||
counts.set(floorId, (counts.get(floorId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */
|
||||
export function describeRoomLocation(
|
||||
room: RoomLike,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): string {
|
||||
const floor =
|
||||
room.floorId === null
|
||||
? undefined
|
||||
: floors.find((entry) => entry.id === room.floorId);
|
||||
if (!floor) {
|
||||
return UNASSIGNED_FLOOR_LABEL;
|
||||
}
|
||||
const buildingId = resolveFloorBuildingId(floor, toIdSet(buildings));
|
||||
const building =
|
||||
buildingId === null
|
||||
? undefined
|
||||
: buildings.find((entry) => entry.id === buildingId);
|
||||
return building ? `${floor.name} · ${building.name}` : floor.name;
|
||||
}
|
||||
|
|
@ -46,6 +46,9 @@ const commandTypeLabels: Record<string, string> = {
|
|||
"circuit.move-group": "Stromkreis in andere Gruppe verschoben",
|
||||
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
|
||||
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
|
||||
"project-building.insert": "Gebäude angelegt",
|
||||
"project-building.update": "Gebäude bearbeitet",
|
||||
"project-building.delete": "Gebäude entfernt",
|
||||
"project-floor.insert": "Geschoss angelegt",
|
||||
"project-floor.update": "Geschoss bearbeitet",
|
||||
"project-floor.delete": "Geschoss entfernt",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
111
src/server/controllers/building.controller.ts
Normal file
111
src/server/controllers/building.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -102,10 +102,22 @@ export const deleteDistributionBoardSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
export const createBuildingSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const updateBuildingSchema = createBuildingSchema;
|
||||
|
||||
// A missing buildingId keeps the current value (older clients send only the
|
||||
// name); null removes the floor from its building.
|
||||
export const createFloorSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
buildingId: z.string().trim().min(1).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue