Integrate project building structure

This commit is contained in:
Julian Appel 2026-09-27 10:45:10 +02:00
commit 868f48e7ac
44 changed files with 5898 additions and 377 deletions

View file

@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "./Sidebar";
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
// Every page shows the (collapsible) sidebar except the print view, which is
// a stand-alone document opened in its own tab and printed as PDF.
const PRINT_VIEW_PATTERN = /^\/projects\/[^/]+\/print(\/|$)/;
const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
const showSidebar = !PRINT_VIEW_PATTERN.test(pathname ?? "");
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
useEffect(() => {

View file

@ -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>

View 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>
))}
</>
);
}

View file

@ -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>