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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue