Merge remote-tracking branch 'origin/main' into feature/cable-sizing

This commit is contained in:
Julian Appel 2026-09-27 16:54:12 +02:00
commit 598003554a
136 changed files with 27182 additions and 1669 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,22 @@ 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: "▥" },
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", 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 +30,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 +56,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
}, []);
useEffect(() => {
if (!activeProjectId) {
if (!overviewProjectId) {
setActiveSection(null);
return;
}
@ -69,7 +83,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 +136,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

@ -21,6 +21,7 @@ import {
Plus,
Redo2,
ShieldPlus,
TableProperties,
Trash2,
Undo2,
} from "lucide-react";
@ -100,6 +101,7 @@ import {
type MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import { createClientId } from "../utils/client-id";
import type {
CellKey,
ColumnDef,
@ -159,6 +161,7 @@ import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { GzfPickerModal } from "./gzf-picker-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { CableSizingModal } from "./cable-sizing-modal";
@ -277,7 +280,7 @@ function createValuesFromEditPatch(
* header heights stay uniform.
*/
function renderColumnHeaderLabel(label: string) {
const match = /^(.*?)s*([[^]]+])$/.exec(label);
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
const name = (match ? match[1] : label).replace(/-/g, "‑");
return match ? (
<>
@ -319,6 +322,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
// GZF cell the AMEV picker writes to; null while the picker is closed.
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
@ -1022,7 +1027,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
defaultSortOrder: number
) {
return buildCircuitDeviceRowInsertSnapshot({
id: crypto.randomUUID(),
id: createClientId(),
circuitId,
values,
defaultSortOrder,
@ -1046,7 +1051,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
data
);
const circuitId = crypto.randomUUID();
const circuitId = createClientId();
const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
);
@ -1214,7 +1219,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(section) => section.id === intent.sectionId
)?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({
id: crypto.randomUUID(),
id: createClientId(),
circuitListId,
role: intent.role,
sectionId: intent.sectionId,
@ -1279,7 +1284,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
projectId,
getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({
id: crypto.randomUUID(),
id: createClientId(),
circuitListId,
category: values.category,
displayName: values.displayName,
@ -1899,22 +1904,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
// Commits the active editor input through command history so edits remain undoable.
// `target` commits a value for a cell without an open inline editor
// (AMEV GZF picker); the regular edit path passes no target.
async function commitEdit(
direction: SaveDirection = "stay",
draftOverride?: string
draftOverride?: string,
target?: SelectedCell
) {
if (!editingCell) {
const session = target ?? editingCell;
if (!session) {
return;
}
const row = findRow(editingCell.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
const row = findRow(session.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
if (!row || !cell || !cell.editable) {
setEditingCell(null);
return;
}
const draft = draftOverride ?? editingCell.draft;
const draft = draftOverride ?? editingCell?.draft ?? "";
if (
editingCell.cellKey === "equipmentIdentifier" &&
session.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
) {
@ -1922,7 +1931,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
const nextSelectionIntent = () => {
let selected = targetSelection;
if (direction !== "stay") {
@ -1945,7 +1954,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => {
const selection = await createFromPlaceholder(
row.sectionId,
editingCell.cellKey,
session.cellKey,
draft
);
targetSelection = selection;
@ -1962,7 +1971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
redo: async () => {
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
await patchCircuit(row.circuit!.id, session.cellKey, next);
return nextSelectionIntent();
},
};
@ -1976,7 +1985,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
redo: async () => {
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
await patchDeviceRow(row.device!.id, session.cellKey, next);
return nextSelectionIntent();
},
};
@ -1991,7 +2000,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(editingCell.cellKey, next)
buildDeviceRowEditPatch(session.cellKey, next)
);
const snapshot = createDeviceRowSnapshot(
row.circuit!.id,
@ -2011,7 +2020,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot,
"Gerätezeile im Reservestromkreis erstellen"
);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
return nextSelectionIntent();
},
};
@ -2020,6 +2029,45 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
return Boolean(
target &&
target.cellKey === "simultaneityFactor" &&
findCell(target.rowKey, target.cellKey)?.editable
);
}
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
// inline edit is discarded because the picked value replaces it.
function openGzfPicker(target: SelectedCell) {
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
return;
}
setEditingCell(null);
setSelectedCell(target);
setGzfPickerCell(target);
}
function closeGzfPicker() {
setGzfPickerCell(null);
focusGridWithoutScroll();
}
// Commits the picked mean value like a typed edit (undoable command).
function handleGzfPicked(value: number) {
const target = gzfPickerCell;
setGzfPickerCell(null);
if (!target) {
return;
}
// parseNumeric accepts only the German decimal comma.
const draft = value.toLocaleString("de-DE", {
maximumFractionDigits: 2,
useGrouping: false,
});
void commitEdit("stay", draft, target);
}
// Cancels current edit session and restores grid keyboard focus.
function cancelEdit() {
if (!editingCell) {
@ -2963,6 +3011,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// - printable key starts overwrite edit (type-to-edit)
// - Arrow/Tab move selectedCell when not editing
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
// The picker modal sits outside the grid; keep grid keys from acting behind it.
if (gzfPickerCell) {
if (event.key === "Escape") {
event.preventDefault();
closeGzfPicker();
}
return;
}
if (editingCell) {
if (event.key === "Tab") {
event.preventDefault();
@ -3030,6 +3086,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
moveHorizontal(-1);
return;
}
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
event.preventDefault();
openGzfPicker(selectedCell);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveVertical(1);
@ -3197,6 +3258,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup}
/>
) : null}
{gzfPickerCell ? (
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
) : null}
{protectionEditorCircuit ? (
<CircuitProtectionModal
equipmentIdentifier={
@ -4530,11 +4594,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} else if (event.key === "Tab") {
event.preventDefault();
void commitEdit(event.shiftKey ? "prev" : "next");
} else if (
event.altKey &&
event.key === "ArrowDown" &&
column.key === "simultaneityFactor"
) {
event.preventDefault();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}
}}
onBlur={() => {
requestAnimationFrame(() => {
const active = document.activeElement as HTMLElement | null;
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
return;
}
if (!active || !containerRef.current?.contains(active)) {
setEditingCell(null);
focusGridWithoutScroll();
@ -4542,6 +4616,27 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
/>
) : column.key === "simultaneityFactor" &&
cell.editable &&
isSelected ? (
<>
<button
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
className="cell-picker-trigger"
disabled={isSaving || historyBusy}
tabIndex={-1}
onClick={(event) => {
event.stopPropagation();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}}
onDoubleClick={(event) => event.stopPropagation()}
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
type="button"
>
<TableProperties aria-hidden="true" size={13} />
</button>
{formatCellValue(cell.value, column.key)}
</>
) : (
formatCellValue(cell.value, column.key)
)}

View file

@ -0,0 +1,110 @@
"use client";
import {
distributionBoardInfoFieldLabels,
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board";
import type { DistributionBoardInfoDto } from "../types";
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
const placeholders: Record<DistributionBoardInfoField, string> = {
boardNumber: "z. B. UV 1.1",
location: "z. B. Raum 003 Technik",
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
remark: "",
};
export function toDistributionBoardInfoDraft(
info: DistributionBoardInfoDto
): DistributionBoardInfoDraft {
return {
boardNumber: info.boardNumber ?? "",
location: info.location ?? "",
fedFrom: info.fedFrom ?? "",
remark: info.remark ?? "",
};
}
export function fromDistributionBoardInfoDraft(
draft: DistributionBoardInfoDraft
): DistributionBoardInfoDto {
const value = (field: DistributionBoardInfoField) =>
draft[field].trim() ? draft[field].trim() : null;
return {
boardNumber: value("boardNumber"),
location: value("location"),
fedFrom: value("fedFrom"),
remark: value("remark"),
};
}
/**
* Descriptive board data used by the print view title block. The values are
* local planning values; a later Revit import may prefill them only through
* an explicit, previewed decision.
*/
export function DistributionBoardInfoCard({
draft,
onChange,
}: {
draft: DistributionBoardInfoDraft;
onChange: (draft: DistributionBoardInfoDraft) => void;
}) {
return (
<section
aria-labelledby="distribution-board-info-heading"
className="card"
>
<div className="card-header" id="distribution-board-info-heading">
Infokarte
</div>
<div className="card-body row g-3">
{distributionBoardInfoFields.map((field) => (
<div
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label
className="form-label"
htmlFor={`distribution-board-info-${field}`}
>
{distributionBoardInfoFieldLabels[field]}
</label>
{field === "remark" ? (
<textarea
className="form-control"
id={`distribution-board-info-${field}`}
maxLength={distributionBoardInfoFieldMaxLengths[field]}
onChange={(event) =>
onChange({ ...draft, [field]: event.target.value })
}
rows={2}
value={draft[field]}
/>
) : (
<input
className="form-control"
id={`distribution-board-info-${field}`}
maxLength={distributionBoardInfoFieldMaxLengths[field]}
onChange={(event) =>
onChange({ ...draft, [field]: event.target.value })
}
placeholder={placeholders[field]}
value={draft[field]}
/>
)}
{field === "fedFrom" ? (
<div className="form-text">
Vorerst Freitext. Die logische Verknüpfung mit dem
speisenden Verteiler folgt.
</div>
) : null}
</div>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,120 @@
"use client";
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
import React, { useEffect, useId, useState, type ReactNode } from "react";
import { IconButton } from "./icon-button";
function readCollapsed(storageKey: string) {
try {
return window.localStorage.getItem(storageKey) === "1";
} catch {
return false;
}
}
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
try {
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
} catch {
// Storage unavailable (private mode, blocked): keep the in-memory state.
}
}
/**
* Collapsible filter area inside a card (PM app style): a small header row
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
* button while a filter is active. Only the bar collapses, never the card.
* The collapsed state is remembered per `storageKey` (default: expanded).
*/
export function CollapsibleFilterPanel({
storageKey,
isFilterActive,
visibleCount,
totalCount,
onReset,
children,
}: {
storageKey: string;
isFilterActive: boolean;
visibleCount: number;
totalCount: number;
onReset: () => void;
children: ReactNode;
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const barId = useId();
useEffect(() => {
setIsCollapsed(readCollapsed(storageKey));
}, [storageKey]);
function toggleCollapsed() {
const next = !isCollapsed;
setIsCollapsed(next);
writeCollapsed(storageKey, next);
}
return (
<div className="filter-panel">
<div className="filter-panel-header">
<IconButton
ariaControls={barId}
ariaExpanded={!isCollapsed}
className="filter-panel-toggle"
icon={isCollapsed ? ChevronRight : ChevronDown}
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
onClick={toggleCollapsed}
/>
<span className="filter-panel-label">Filter</span>
{isCollapsed && isFilterActive ? (
<span className="filter-panel-badge">Filter aktiv</span>
) : null}
<span aria-live="polite" className="filter-panel-count">
{visibleCount} von {totalCount}
</span>
</div>
{isCollapsed ? null : (
<div className="filter-bar" id={barId}>
{children}
{isFilterActive ? (
<IconButton
className="filter-bar-reset"
icon={FilterX}
label="Zurücksetzen"
onClick={onReset}
showLabel
title="Alle Filter zurücksetzen"
/>
) : null}
</div>
)}
</div>
);
}
/** Search field for a filter bar. */
export function FilterSearchField({
label,
placeholder,
value,
onChange,
}: {
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="filter-bar-field">
<span className="filter-bar-label">{label}</span>
<input
className="filter-bar-search"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="search"
value={value}
/>
</label>
);
}

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,5 +1,6 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
interface GzfPickerModalProps {
@ -12,14 +13,50 @@ function formatFactor(value: number) {
}
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
// Wait for the inline editor's blur callback before taking keyboard focus.
const frame = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
});
return () => cancelAnimationFrame(frame);
}, []);
return (
<>
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
<div
aria-labelledby={titleId}
aria-modal="true"
className="modal fade show d-block"
ref={dialogRef}
role="dialog"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === "Tab") {
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
const first = buttons?.[0];
const last = buttons?.[buttons.length - 1];
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
}}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<div>
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
<p className="text-secondary small mb-0">
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
</p>

View file

@ -1,7 +1,7 @@
"use client";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import React, { type ReactNode } from "react";
/**
* Compact editor button with a lucide icon. `label` is always the accessible

View file

@ -0,0 +1,40 @@
"use client";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
/**
* Navigation counterpart of `IconButton`: a Next.js link with the same
* look. `label` is always the accessible name and, unless `title` is given,
* the tooltip; with `showLabel` it is also rendered next to the icon.
*/
export function IconLink({
icon: Icon,
label,
title,
href,
variant = "default",
showLabel = false,
className,
}: {
icon: LucideIcon;
label: string;
title?: string;
href: string;
variant?: "default" | "danger" | "primary";
showLabel?: boolean;
className?: string;
}) {
return (
<Link
aria-label={showLabel ? undefined : label}
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
href={href}
title={title ?? label}
>
<Icon aria-hidden="true" size={15} strokeWidth={2} />
{showLabel ? <span>{label}</span> : null}
</Link>
);
}

View file

@ -0,0 +1,127 @@
"use client";
import { ChevronDown } from "lucide-react";
import React, { useEffect, useId, useRef, useState } from "react";
import {
toggleFilterValue,
type FilterOption,
} from "../utils/overview-filters";
/**
* Filter dropdown with a checkbox list (PM app style). An empty selection
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
* click and Escape.
*/
export function MultiSelectDropdown({
label,
options,
selected,
onChange,
emptyLabel = "Keine Einträge vorhanden",
}: {
label: string;
options: readonly FilterOption[];
selected: readonly string[];
onChange: (selected: string[]) => void;
emptyLabel?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const labelId = useId();
const valueId = useId();
const panelId = useId();
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
toggleRef.current?.focus();
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const summary =
selected.length === 0
? "Alle"
: selected.length === 1
? options.find((option) => option.id === selected[0])?.label ??
"1 ausgewählt"
: `${selected.length} ausgewählt`;
return (
<div className="filter-dropdown" ref={containerRef}>
<span className="filter-bar-label" id={labelId}>
{label}
</span>
<button
aria-controls={panelId}
aria-expanded={isOpen}
aria-haspopup="true"
aria-labelledby={`${labelId} ${valueId}`}
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
onClick={() => setIsOpen((current) => !current)}
ref={toggleRef}
title={summary}
type="button"
>
<span className="filter-dropdown-value" id={valueId}>
{summary}
</span>
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
</button>
{isOpen ? (
<div
aria-labelledby={labelId}
className="filter-dropdown-panel"
id={panelId}
role="group"
>
{options.length === 0 ? (
<div className="filter-dropdown-empty">{emptyLabel}</div>
) : (
<>
<label className="filter-dropdown-option filter-dropdown-option-all">
<input
checked={selected.length === 0}
onChange={() => onChange([])}
type="checkbox"
/>
Alle
</label>
{options.map((option) => (
<label className="filter-dropdown-option" key={option.id}>
<input
checked={selected.includes(option.id)}
onChange={() =>
onChange(toggleFilterValue(selected, option.id))
}
type="checkbox"
/>
{option.label}
</label>
))}
</>
)}
</div>
) : null}
</div>
);
}

View file

@ -7,9 +7,16 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
import {
projectCoverFieldLabels,
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput {
export interface ProjectSettingsInput
extends Record<ProjectCoverField, string | null> {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
siteAddress: "Straße Nr.\nPLZ Ort",
workStage: "z. B. LPH 5 – Ausführungsplanung",
projectLead: "",
projectEditor: "",
};
interface ProjectSettingsModalProps {
isSaving: boolean;
onClose: () => void;
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes
);
const [coverValues, setCoverValues] = useState<
Record<ProjectCoverField, string>
>({
siteAddress: project.siteAddress ?? "",
workStage: project.workStage ?? "",
projectLead: project.projectLead ?? "",
projectEditor: project.projectEditor ?? "",
});
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes,
siteAddress: toNullableString(coverValues.siteAddress),
workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor),
});
}
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Deckblatt</h3>
<p className="text-secondary small">
Angaben für das Deckblatt der Seitenansicht. Büroname und
Büroanschrift werden projektübergreifend in der Seitenansicht
gepflegt.
</p>
<div className="row g-3">
{projectCoverFields.map((field) => (
<div
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label className="form-label" htmlFor={`project-cover-${field}`}>
{projectCoverFieldLabels[field]}
</label>
{field === "siteAddress" ? (
<textarea
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
rows={2}
value={coverValues[field]}
/>
) : (
<input
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
value={coverValues[field]}
/>
)}
</div>
))}
</div>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>

View file

@ -28,6 +28,14 @@ import {
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages,
} from "../utils/project-version-history";
import {
ChevronDown,
ChevronUp,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button";
interface ProjectVersionHistoryProps {
projectId: string;
@ -234,50 +242,50 @@ export function ProjectVersionHistory({
<span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision}
</span>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.undoDepth
}
onClick={() => void handleHistoryCommand("undo")}
title={
history?.undoDepth
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
: "Keine Änderung zum Rückgängigmachen"
}
type="button"
>
Rückgängig
</button>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.redoDepth
}
onClick={() => void handleHistoryCommand("redo")}
title={
history?.redoDepth
? `${history.redoDepth} Änderung(en) können wiederholt werden`
: "Keine Änderung zum Wiederholen"
}
type="button"
>
Wiederholen
</button>
<button
aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary"
onClick={() => setIsOpen((current) => !current)}
type="button"
>
{isOpen ? "Ausblenden" : "Anzeigen"}
</button>
<div className="overview-actions">
<IconButton
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.undoDepth
}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleHistoryCommand("undo")}
showLabel
title={
history?.undoDepth
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
: "Keine Änderung zum Rückgängigmachen"
}
/>
<IconButton
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.redoDepth
}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleHistoryCommand("redo")}
showLabel
title={
history?.redoDepth
? `${history.redoDepth} Änderung(en) können wiederholt werden`
: "Keine Änderung zum Wiederholen"
}
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isOpen}
icon={isOpen ? ChevronUp : ChevronDown}
label={isOpen ? "Ausblenden" : "Anzeigen"}
onClick={() => setIsOpen((current) => !current)}
showLabel
/>
</div>
</div>
</div>
{error ? (
@ -381,14 +389,15 @@ export function ProjectVersionHistory({
</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<button
className="btn btn-sm btn-outline-danger"
disabled={isBusy}
onClick={() => setRestoreCandidate(snapshot)}
type="button"
>
Wiederherstellen
</button>
<div className="overview-actions justify-content-end">
<IconButton
disabled={isBusy}
icon={RotateCcw}
label="Wiederherstellen"
onClick={() => setRestoreCandidate(snapshot)}
variant="danger"
/>
</div>
</td>
</tr>
))}

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>

View file

@ -0,0 +1,162 @@
"use client";
import { type FormEvent, useState } from "react";
import { FormModal } from "../components/form-modal";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
EstimateBalanceTemplate,
} from "../types";
import { estimateTextLimits } from "./estimate-balance-document";
import { getTodayIsoDate } from "./estimate-balance-format";
export interface EstimateBalanceCreateDefaults {
name?: string;
workStage?: string | null;
template?: EstimateBalanceTemplate;
copyFromBalanceId?: string;
}
interface EstimateBalanceCreateModalProps {
balances: EstimateBalanceDto[];
defaults: EstimateBalanceCreateDefaults;
isSaving: boolean;
onClose: () => void;
onCreate: (input: CreateEstimateBalanceInput) => void;
}
/** "Neuer Stand": name, LPH, date and the starting content. */
export function EstimateBalanceCreateModal({
balances,
defaults,
isSaving,
onClose,
onCreate,
}: EstimateBalanceCreateModalProps) {
const [name, setName] = useState(defaults.name ?? "");
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
const [dateIso, setDateIso] = useState(getTodayIsoDate());
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
defaults.template ?? "kg400"
);
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
);
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim() || isCopyInvalid) {
return;
}
onCreate({
name: name.trim(),
workStage: workStage.trim() || null,
dateIso: dateIso || null,
template,
...(template === "copy" ? { copyFromBalanceId } : {}),
});
}
return (
<FormModal
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!name.trim() || isCopyInvalid}
submitLabel="Stand anlegen"
title="Neuer Stand"
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="estimate-create-name"
maxLength={estimateTextLimits.balanceName}
onChange={(event) => setName(event.target.value)}
placeholder="z. B. Entwurfsplanung"
required
value={name}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="estimate-create-work-stage">
LPH
</label>
<input
className="form-control"
id="estimate-create-work-stage"
maxLength={estimateTextLimits.workStage}
onChange={(event) => setWorkStage(event.target.value)}
placeholder="z. B. LPH 3"
value={workStage}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="estimate-create-date">
Datum
</label>
<input
className="form-control"
id="estimate-create-date"
onChange={(event) => setDateIso(event.target.value)}
type="date"
value={dateIso}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-template">
Inhalt
</label>
<select
className="form-select"
id="estimate-create-template"
onChange={(event) =>
setTemplate(event.target.value as EstimateBalanceTemplate)
}
value={template}
>
<option value="kg400">Vorlage KG 400</option>
<option value="empty">Leer</option>
<option disabled={!balances.length} value="copy">
Kopie von …
</option>
</select>
</div>
{template === "copy" ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-copy-from">
Kopie von Stand
</label>
<select
className="form-select"
id="estimate-create-copy-from"
onChange={(event) => setCopyFromBalanceId(event.target.value)}
required
value={copyFromBalanceId}
>
{balances.map((balance) => (
<option key={balance.id} value={balance.id}>
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
</option>
))}
</select>
</div>
) : null}
<div className="col-12">
<p className="text-secondary small mb-0">
{template === "kg400"
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
: template === "empty"
? "Legt einen Stand ohne Kostengruppen an."
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
</p>
</div>
</div>
</FormModal>
);
}

View file

@ -0,0 +1,298 @@
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
// The editor keeps the complete document as local draft and saves it as a
// whole (PUT), so every edit is a function draft → new draft.
import type {
EstimateBalanceSnapshot,
EstimateGroupSnapshot,
EstimateRowSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
import { createClientId } from "../utils/client-id";
export type MoveDirection = -1 | 1;
/** Maximum text lengths accepted by the server. */
export const estimateTextLimits = {
balanceName: 200,
workStage: 50,
costGroup: 20,
groupTitle: 200,
groupEditor: 100,
supply: 50,
rowName: 200,
remark: 500,
} as const;
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
export function createEstimateRow(
sortOrder: number,
createId: () => string = createClientId
): EstimateRowSnapshot {
return {
id: createId(),
sortOrder,
supply: null,
name: "",
quantity: 1,
unit: "piece",
connection: null,
powerPerUnitW: 0,
simultaneityFactor: 1,
cosPhi: 1,
safetySupply: false,
remark: null,
};
}
export function createEstimateGroup(
input: { costGroup: string; title: string; editor?: string | null },
sortOrder: number,
createId: () => string = createClientId
): EstimateGroupSnapshot {
return {
id: createId(),
costGroup: input.costGroup,
title: input.title,
editor: input.editor ?? null,
sortOrder,
rows: [],
};
}
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
return left.sortOrder - right.sortOrder;
}
/** Orders groups and rows by sortOrder and renumbers both from 0. */
export function normalizeEstimateSortOrder(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
return {
...balance,
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
...group,
sortOrder: groupIndex,
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
...row,
sortOrder: rowIndex,
})),
})),
};
}
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
return items.map((item, index) =>
item.sortOrder === index ? item : { ...item, sortOrder: index }
);
}
function mapGroup(
balance: EstimateBalanceSnapshot,
groupId: string,
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
): EstimateBalanceSnapshot {
let changed = false;
const groups = balance.groups.map((group) => {
if (group.id !== groupId) {
return group;
}
const next = update(group);
changed = next !== group;
return next;
});
// An unknown group or a no-op keeps the identical document.
return changed ? { ...balance, groups } : balance;
}
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
const target = index + direction;
if (index < 0 || target < 0 || target >= items.length) {
return null;
}
const next = [...items];
[next[index], next[target]] = [next[target]!, next[index]!];
return next;
}
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
export function addEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
options: { afterRowId?: string; createId?: () => string } = {}
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
let rowId: string | null = null;
const next = mapGroup(balance, groupId, (group) => {
const row = createEstimateRow(0, options.createId);
rowId = row.id;
const afterIndex = options.afterRowId
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
: -1;
const rows = [...group.rows];
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
return { ...group, rows: renumber(rows) };
});
return { balance: next, rowId };
}
export function updateEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
rowId: string,
patch: EstimateRowPatch
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => ({
...group,
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
}));
}
export function removeEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
rowId: string
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => ({
...group,
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
}));
}
export function moveEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
rowId: string,
direction: MoveDirection
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => {
const rows = moveItem(
group.rows,
group.rows.findIndex((row) => row.id === rowId),
direction
);
return rows ? { ...group, rows: renumber(rows) } : group;
});
}
/** Appends a group and returns its id. */
export function addEstimateGroup(
balance: EstimateBalanceSnapshot,
input: { costGroup: string; title: string; editor?: string | null },
createId: () => string = createClientId
): { balance: EstimateBalanceSnapshot; groupId: string } {
const group = createEstimateGroup(input, balance.groups.length, createId);
return {
balance: { ...balance, groups: renumber([...balance.groups, group]) },
groupId: group.id,
};
}
export function updateEstimateGroup(
balance: EstimateBalanceSnapshot,
groupId: string,
patch: EstimateGroupPatch
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
}
export function removeEstimateGroup(
balance: EstimateBalanceSnapshot,
groupId: string
): EstimateBalanceSnapshot {
const groups = balance.groups.filter((group) => group.id !== groupId);
return groups.length === balance.groups.length
? balance
: { ...balance, groups: renumber(groups) };
}
export function moveEstimateGroup(
balance: EstimateBalanceSnapshot,
groupId: string,
direction: MoveDirection
): EstimateBalanceSnapshot {
const groups = moveItem(
balance.groups,
balance.groups.findIndex((group) => group.id === groupId),
direction
);
return groups ? { ...balance, groups: renumber(groups) } : balance;
}
function requiredText(value: string) {
return value.trim();
}
function optionalText(value: string | null) {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed : null;
}
/**
* The exact document the server accepts: strings trimmed, empty optional
* texts as null, sort orders continuous. Only fields of the shared snapshot
* type are copied, so no UI state can leak into the request.
*/
export function toSaveableEstimateBalance(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
const normalized = normalizeEstimateSortOrder(balance);
return {
id: normalized.id,
projectId: normalized.projectId,
name: requiredText(normalized.name),
workStage: optionalText(normalized.workStage),
dateIso: normalized.dateIso || null,
reserveAvPercent: normalized.reserveAvPercent,
reserveSvPercent: normalized.reserveSvPercent,
sortOrder: normalized.sortOrder,
groups: normalized.groups.map((group) => ({
id: group.id,
costGroup: requiredText(group.costGroup),
title: requiredText(group.title),
editor: optionalText(group.editor),
sortOrder: group.sortOrder,
rows: group.rows.map((row) => ({
id: row.id,
sortOrder: row.sortOrder,
supply: optionalText(row.supply),
name: requiredText(row.name),
quantity: row.quantity,
unit: row.unit,
connection: row.connection,
powerPerUnitW: row.powerPerUnitW,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
safetySupply: row.safetySupply,
remark: optionalText(row.remark),
})),
})),
};
}
/** Stable comparison key of the saveable document (dirty detection). */
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
return JSON.stringify(toSaveableEstimateBalance(balance));
}
/**
* German problems that would make the server reject the document. The
* editor never saves while this list is not empty.
*/
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
const problems: string[] = [];
const saveable = toSaveableEstimateBalance(balance);
if (!saveable.name) {
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
}
for (const group of saveable.groups) {
if (!group.title) {
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
}
}
return problems;
}
export function createCopyName(name: string) {
const copyName = `${name.trim()} (Kopie)`;
return copyName.slice(0, estimateTextLimits.balanceName);
}

View file

@ -0,0 +1,122 @@
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
// Numbers are entered and shown in German notation. As in the circuit list
// editor, only "," is accepted as decimal separator: a "." is ambiguous
// (decimal point or thousands separator) and is rejected instead of guessed.
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
export type DecimalParseResult =
| { ok: true; value: number }
| { ok: false; error: string };
export interface DecimalRange {
min?: number;
/** Excludes `min` itself (e.g. cos φ > 0). */
minExclusive?: boolean;
max?: number;
}
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
function formatBound(value: number) {
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
}
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
const trimmed = text.trim();
if (!trimmed) {
return { ok: false, error: "Bitte einen Wert eingeben." };
}
if (trimmed.includes(".")) {
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
}
if (!DECIMAL_PATTERN.test(trimmed)) {
return { ok: false, error: "Ungültiger Zahlenwert." };
}
const value = Number(trimmed.replace(",", "."));
if (!Number.isFinite(value)) {
return { ok: false, error: "Ungültiger Zahlenwert." };
}
if (range.min !== undefined) {
if (range.minExclusive ? value <= range.min : value < range.min) {
return {
ok: false,
error: range.minExclusive
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
};
}
}
if (range.max !== undefined && value > range.max) {
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
}
return { ok: true, value };
}
/** Value ranges accepted by the server for the numeric document fields. */
export const estimateNumberRanges = {
quantity: { min: 0 },
powerPerUnitW: { min: 0 },
simultaneityFactor: { min: 0, max: 1 },
cosPhi: { min: 0, minExclusive: true, max: 1 },
reservePercent: { min: 0, max: 100 },
} satisfies Record<string, DecimalRange>;
/**
* Text of a numeric input field: German comma, no thousands separator, so the
* shown text can be parsed again unchanged.
*/
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
}
/**
* Read-only display of a calculated value with thousands separator ("1.200",
* "30,24"). The value is rounded half away from zero first, like Excel does
* (40,175 → 40,18 instead of the binary-rounded 40,17).
*/
export function formatGermanNumber(
value: number,
minimumFractionDigits: number,
maximumFractionDigits = minimumFractionDigits
): string {
const rounded = roundEstimateValue(value, maximumFractionDigits);
// "+ 0" turns -0 into 0 and avoids "-0,00".
return (rounded + 0).toLocaleString("de-DE", {
minimumFractionDigits,
maximumFractionDigits,
});
}
export function formatKw(value: number) {
return formatGermanNumber(value, 2);
}
export function formatAmpere(value: number) {
return formatGermanNumber(value, 2);
}
export function formatSignedKw(value: number) {
const text = formatKw(value);
return value > 0 && text !== "0,00" ? `+${text}` : text;
}
export function formatDeltaPercent(value: number | null) {
if (value === null) {
return "–";
}
const text = formatGermanNumber(value, 1);
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
}
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
export function formatIsoDateGerman(dateIso: string | null): string {
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
}
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
export function getTodayIsoDate(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}

View file

@ -0,0 +1,669 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import {
calculateEstimateBalance,
calculateEstimateRow,
estimateRowConnectionLabels,
estimateRowUnitLabels,
type EstimateBalanceSnapshot,
type EstimateGroupSnapshot,
type EstimatePower,
type EstimateRowConnection,
type EstimateRowSnapshot,
type EstimateRowUnit,
} from "../../shared/estimate-balance/estimate-balance";
import {
addEstimateRow,
estimateTextLimits,
moveEstimateGroup,
removeEstimateGroup,
removeEstimateRow,
updateEstimateGroup,
updateEstimateRow,
type EstimateRowPatch,
} from "./estimate-balance-document";
import {
estimateNumberRanges,
formatAmpere,
formatDecimalInput,
formatKw,
parseGermanDecimal,
type DecimalRange,
formatGermanNumber,
} from "./estimate-balance-format";
import { DraftInput } from "./estimate-draft-input";
export type EstimateBalanceUpdate = (
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
) => void;
interface EstimateBalanceSheetProps {
balance: EstimateBalanceSnapshot;
disabled: boolean;
onAddGroup: () => void;
onChange: EstimateBalanceUpdate;
onEditGroup: (group: EstimateGroupSnapshot) => void;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
threePhaseVoltageV: number;
}
// Leading columns up to cos φ; the power columns follow.
const LEADING_COLUMN_COUNT = 9;
const COLUMN_COUNT = 16;
/** Suggested supply values; free text stays possible. */
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
const SUPPLY_LIST_ID = "estimate-supply-options";
function NumberInput({
ariaLabel,
disabled,
fieldKey,
onCommit,
onValidityChange,
range,
value,
}: {
ariaLabel: string;
disabled: boolean;
fieldKey: string;
onCommit: (value: number) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
range: DecimalRange;
value: number;
}) {
return (
<DraftInput
ariaLabel={ariaLabel}
className="estimate-cell-input is-numeric"
disabled={disabled}
format={(current: number) => formatDecimalInput(current)}
inputMode="decimal"
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
onCommit={onCommit}
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
parse={(text) => parseGermanDecimal(text, range)}
value={value}
/>
);
}
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
const used = balance.groups.flatMap((group) =>
group.rows.map((row) => row.supply?.trim() ?? "")
);
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
}
function TextInput({
ariaLabel,
disabled,
list,
maxLength,
onChange,
placeholder,
value,
}: {
ariaLabel: string;
disabled: boolean;
list?: string;
maxLength: number;
onChange: (value: string) => void;
placeholder?: string;
value: string | null;
}) {
return (
<>
<input
aria-label={ariaLabel}
className="estimate-cell-input"
disabled={disabled}
list={list}
maxLength={maxLength}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="text"
value={value ?? ""}
/>
<span className="estimate-print-value">{value ?? ""}</span>
</>
);
}
function IconButton({
children,
disabled,
label,
onClick,
variant = "secondary",
}: {
children: ReactNode;
disabled: boolean;
label: string;
onClick: () => void;
variant?: "secondary" | "danger" | "primary";
}) {
return (
<button
aria-label={label}
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
disabled={disabled}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
return (
<>
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
</>
);
}
function groupLabel(group: EstimateGroupSnapshot) {
return [group.costGroup, group.title].filter(Boolean).join(" ");
}
function EstimateRowLine({
disabled,
group,
onChange,
onOpenGzfPicker,
onValidityChange,
row,
rowIndex,
}: {
disabled: boolean;
group: EstimateGroupSnapshot;
onChange: EstimateBalanceUpdate;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
row: EstimateRowSnapshot;
rowIndex: number;
}) {
const result = calculateEstimateRow(row);
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
const fieldKey = (field: string) => `${row.id}:${field}`;
function patch(rowPatch: EstimateRowPatch) {
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
}
return (
<tr className="estimate-row">
<td className="estimate-cost-group-cell" />
<td>
<TextInput
ariaLabel={`Versorgung (${rowLabel})`}
disabled={disabled}
list={SUPPLY_LIST_ID}
maxLength={estimateTextLimits.supply}
onChange={(supply) => patch({ supply })}
placeholder="ELT"
value={row.supply}
/>
</td>
<td className="estimate-name-cell">
<TextInput
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.rowName}
onChange={(name) => patch({ name })}
placeholder="Verbraucher oder Fläche"
value={row.name}
/>
</td>
<td>
<NumberInput
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("quantity")}
onCommit={(quantity) => patch({ quantity })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.quantity}
value={row.quantity}
/>
</td>
<td>
<select
aria-label={`Einheit (${rowLabel})`}
className="estimate-cell-select"
disabled={disabled}
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
value={row.unit}
>
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
<option key={unit} value={unit}>
{estimateRowUnitLabels[unit]}
</option>
))}
</select>
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
</td>
<td>
<select
aria-label={`Anschluss (${rowLabel})`}
className="estimate-cell-select"
disabled={disabled}
onChange={(event) =>
patch({
connection: (event.target.value || null) as EstimateRowConnection | null,
})
}
value={row.connection ?? ""}
>
<option value="">–</option>
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
(connection) => (
<option key={connection} value={connection}>
{estimateRowConnectionLabels[connection]}
</option>
)
)}
</select>
<span className="estimate-print-value">
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
</span>
</td>
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("powerPerUnitW")}
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.powerPerUnitW}
value={row.powerPerUnitW}
/>
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
</div>
</td>
<td>
<div className="estimate-gzf-input">
<NumberInput
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("simultaneityFactor")}
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.simultaneityFactor}
value={row.simultaneityFactor}
/>
<button
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
className="estimate-amev-button estimate-no-print"
disabled={disabled}
onClick={() => onOpenGzfPicker(group.id, row.id)}
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
type="button"
>
AMEV
</button>
</div>
</td>
<td>
<NumberInput
ariaLabel={`cos φ (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("cosPhi")}
onCommit={(cosPhi) => patch({ cosPhi })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.cosPhi}
value={row.cosPhi}
/>
</td>
<PowerCells power={result.av} />
<td className="estimate-center">
<input
aria-label={`Sicherheitsversorgung (${rowLabel})`}
checked={row.safetySupply}
className="form-check-input estimate-no-print"
disabled={disabled}
onChange={(event) => patch({ safetySupply: event.target.checked })}
type="checkbox"
/>
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
</td>
<PowerCells
className={row.safetySupply ? "" : "is-muted"}
power={result.sv}
/>
<td>
<TextInput
ariaLabel={`Anmerkung (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.remark}
onChange={(remark) => patch({ remark })}
value={row.remark}
/>
</td>
<td className="estimate-actions-cell estimate-no-print">
<IconButton
disabled={disabled}
label={`Zeile löschen (${rowLabel})`}
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
variant="danger"
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</td>
</tr>
);
}
/**
* Spreadsheet-like editor in the layout of the office template
* "Leistungsbilanz – KG400": one section per cost group with title and
* subtotal row, totals, reserve and maximum current at the end.
*/
export function EstimateBalanceSheet({
balance,
disabled,
onAddGroup,
onChange,
onEditGroup,
onOpenGzfPicker,
onValidityChange,
threePhaseVoltageV,
}: EstimateBalanceSheetProps) {
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
const rowText =
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
if (
window.confirm(
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
)
) {
onChange((current) => removeEstimateGroup(current, group.id));
}
}
return (
<div className="estimate-sheet-wrap">
<datalist id={SUPPLY_LIST_ID}>
{supplyOptions(balance).map((supply) => (
<option key={supply} value={supply} />
))}
</datalist>
<table className="estimate-sheet">
<colgroup>
<col className="estimate-col-kg" />
<col className="estimate-col-supply" />
<col className="estimate-col-name" />
<col className="estimate-col-quantity" />
<col className="estimate-col-unit" />
<col className="estimate-col-connection" />
<col className="estimate-col-power" />
<col className="estimate-col-gzf" />
<col className="estimate-col-cosphi" />
<col className="estimate-col-result" />
<col className="estimate-col-result" />
<col className="estimate-col-sv" />
<col className="estimate-col-result" />
<col className="estimate-col-result" />
<col className="estimate-col-remark" />
<col className="estimate-col-actions" />
</colgroup>
<thead>
<tr>
<th rowSpan={2}>KG</th>
<th rowSpan={2}>
Versorgung
<span className="estimate-th-hint">ELT/MSR …</span>
</th>
<th rowSpan={2}>Verbraucher / Flächen</th>
<th colSpan={2}>
Anzahl / Fläche
<span className="estimate-th-hint">St. / m²</span>
</th>
<th rowSpan={2}>
Anschl.
<span className="estimate-th-hint">Fest/Steckd.</span>
</th>
<th rowSpan={2}>
Einzelleist.
<span className="estimate-th-hint">W bzw. W/m²</span>
</th>
<th rowSpan={2}>GL-Faktor</th>
<th rowSpan={2}>cos φ</th>
<th colSpan={2}>Nötige AV-Leistung</th>
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
<th rowSpan={2}>Anmerkungen</th>
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
</tr>
<tr>
<th>Wert</th>
<th>Einh.</th>
<th>kW</th>
<th>kVA</th>
<th>ja/nein</th>
<th>kW</th>
<th>kVA</th>
</tr>
</thead>
{balance.groups.map((group, groupIndex) => {
const groupResult = result.groups[groupIndex]!;
const label = groupLabel(group) || "Kostengruppe";
return (
<tbody className="estimate-group" key={group.id}>
<tr className="estimate-group-title-row">
<td className="estimate-cost-group-cell">{group.costGroup}</td>
<td colSpan={COLUMN_COUNT - 2}>
<div className="estimate-group-title">
<span className="estimate-group-name">{group.title}</span>
<label
className={
group.editor?.trim()
? "estimate-group-editor"
: "estimate-group-editor is-empty"
}
>
<span>bearbeitet von</span>
<input
aria-label={`Bearbeitet von (${label})`}
className="estimate-cell-input"
disabled={disabled}
maxLength={estimateTextLimits.groupEditor}
onChange={(event) =>
onChange((current) =>
updateEstimateGroup(current, group.id, {
editor: event.target.value,
})
)
}
placeholder="Gewerk / Name"
type="text"
value={group.editor ?? ""}
/>
<span className="estimate-print-value">{group.editor ?? ""}</span>
</label>
</div>
</td>
<td className="estimate-actions-cell estimate-no-print">
<div className="estimate-group-actions">
<IconButton
disabled={disabled}
label={`Zeile hinzufügen (${label})`}
onClick={() =>
onChange((current) => addEstimateRow(current, group.id).balance)
}
variant="primary"
>
<Plus aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Gruppe bearbeiten (${label})`}
onClick={() => onEditGroup(group)}
>
<Pencil aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled || groupIndex === 0}
label={`Gruppe nach oben (${label})`}
onClick={() =>
onChange((current) => moveEstimateGroup(current, group.id, -1))
}
>
<ArrowUp aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled || groupIndex === balance.groups.length - 1}
label={`Gruppe nach unten (${label})`}
onClick={() =>
onChange((current) => moveEstimateGroup(current, group.id, 1))
}
>
<ArrowDown aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Gruppe löschen (${label})`}
onClick={() => confirmRemoveGroup(group)}
variant="danger"
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</div>
</td>
</tr>
{group.rows.map((row, rowIndex) => (
<EstimateRowLine
disabled={disabled}
group={group}
key={row.id}
onChange={onChange}
onOpenGzfPicker={onOpenGzfPicker}
onValidityChange={onValidityChange}
row={row}
rowIndex={rowIndex}
/>
))}
{!group.rows.length ? (
<tr className="estimate-empty-row estimate-no-print">
<td />
<td colSpan={COLUMN_COUNT - 1}>
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
</td>
</tr>
) : null}
<tr className="estimate-subtotal-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Summe {label}
</td>
<PowerCells power={groupResult.av} />
<td />
<PowerCells power={groupResult.sv} />
<td />
<td className="estimate-no-print" />
</tr>
</tbody>
);
})}
<tfoot>
<tr className="estimate-no-print estimate-add-group-row">
<td colSpan={COLUMN_COUNT}>
<button
className="btn btn-sm btn-outline-primary"
disabled={disabled}
onClick={onAddGroup}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={14} />
Kostengruppe hinzufügen
</button>
</td>
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Gesamtsumme
</td>
<PowerCells power={result.total.av} />
<td />
<PowerCells power={result.total.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
Reserveleistung
</td>
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel="Reserve AV in Prozent"
disabled={disabled}
fieldKey="balance:reserveAvPercent"
onCommit={(reserveAvPercent) =>
onChange((current) => ({ ...current, reserveAvPercent }))
}
onValidityChange={onValidityChange}
range={estimateNumberRanges.reservePercent}
value={balance.reserveAvPercent}
/>
<span className="estimate-unit-label">%</span>
</div>
</td>
<PowerCells power={result.reserve.av} />
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel="Reserve SV in Prozent"
disabled={disabled}
fieldKey="balance:reserveSvPercent"
onCommit={(reserveSvPercent) =>
onChange((current) => ({ ...current, reserveSvPercent }))
}
onValidityChange={onValidityChange}
range={estimateNumberRanges.reservePercent}
value={balance.reserveSvPercent}
/>
<span className="estimate-unit-label">%</span>
</div>
</td>
<PowerCells power={result.reserve.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row is-strong">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Leistung inkl. Reserve
</td>
<PowerCells power={result.withReserve.av} />
<td />
<PowerCells power={result.withReserve.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Maximalstrom
<span className="estimate-total-hint">
{threePhaseVoltageV > 0
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
: " (Drehstromspannung im Projekt fehlt)"}
</span>
</td>
<td className="estimate-number" colSpan={2}>
{formatAmpere(result.maxCurrentA.av)} A
</td>
<td />
<td className="estimate-number" colSpan={2}>
{formatAmpere(result.maxCurrentA.sv)} A
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import {
roundEstimateValue,
type EstimateComparison,
} from "../../shared/estimate-balance/estimate-balance";
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
function deltaClass(deltaKw: number) {
if (roundEstimateValue(deltaKw, 2) === 0) {
return "estimate-number";
}
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
}
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
return (
<div className="estimate-sheet-wrap">
<table className="estimate-sheet estimate-comparison">
<thead>
<tr>
<th>KG</th>
<th>Titel</th>
<th>geschätzt AV kW</th>
<th>detailliert kW</th>
<th>Δ kW</th>
<th>Δ %</th>
</tr>
</thead>
<tbody>
{comparison.groups.map((group) => (
<tr key={group.groupId}>
<td className="estimate-cost-group-cell">{group.costGroup}</td>
<td>
{group.title}
{group.detailedRowCount ? (
<span className="estimate-th-hint">
{group.detailedRowCount === 1
? "1 Gerätezeile"
: `${group.detailedRowCount} Gerätezeilen`}
</span>
) : null}
</td>
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
<td className={deltaClass(group.deltaKw)}>
{formatDeltaPercent(group.deltaPercent)}
</td>
</tr>
))}
<tr className="estimate-unassigned-row">
<td />
<td>
ohne Kostengruppe / nicht zugeordnet
{comparison.unassigned.rowCount ? (
<span className="estimate-th-hint">
{comparison.unassigned.rowCount === 1
? "1 Gerätezeile"
: `${comparison.unassigned.rowCount} Gerätezeilen`}
</span>
) : null}
</td>
<td className="estimate-number">–</td>
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
<td className="estimate-number">–</td>
<td className="estimate-number">–</td>
</tr>
</tbody>
<tfoot>
<tr className="estimate-total-row is-strong">
<td />
<td className="estimate-total-label">Summe</td>
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
<td className={deltaClass(comparison.total.deltaKw)}>
{formatSignedKw(comparison.total.deltaKw)}
</td>
<td className={deltaClass(comparison.total.deltaKw)}>
{formatDeltaPercent(comparison.total.deltaPercent)}
</td>
</tr>
</tfoot>
</table>
</div>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useEffect, useRef, useState } from "react";
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
interface DraftInputProps<T> {
ariaLabel: string;
className?: string;
disabled?: boolean;
format: (value: T) => string;
/** Printed text; defaults to the input text. */
printFormat?: (value: T) => string;
inputMode?: "decimal" | "text";
maxLength?: number;
onCommit: (value: T) => void;
/** Reports whether the raw text is currently valid (false while invalid). */
onValidityChange?: (isValid: boolean) => void;
parse: (text: string) => DraftParseResult<T>;
placeholder?: string;
value: T;
}
/**
* Input that keeps the raw text the user typed. Only a valid text is
* committed into the document; an invalid text stays visible with a red
* border and is never saved. A value changed from outside (reload, undo)
* replaces the text unless the text already represents that value.
*/
export function DraftInput<T>({
ariaLabel,
className = "estimate-cell-input",
disabled,
format,
printFormat,
inputMode = "text",
maxLength,
onCommit,
onValidityChange,
parse,
placeholder,
value,
}: DraftInputProps<T>) {
const [text, setText] = useState(() => format(value));
const [error, setError] = useState<string | null>(null);
const [syncedValue, setSyncedValue] = useState(value);
if (!Object.is(syncedValue, value)) {
// Adjusting state while rendering (instead of an effect) avoids a frame
// with stale text after an external change.
setSyncedValue(value);
const parsed = parse(text);
if (!parsed.ok || !Object.is(parsed.value, value)) {
setText(format(value));
setError(null);
}
}
const validityRef = useRef(onValidityChange);
useEffect(() => {
validityRef.current = onValidityChange;
});
const isValid = error === null;
useEffect(() => {
validityRef.current?.(isValid);
}, [isValid]);
// A removed input (deleted row) must not leave an invalid marker behind.
useEffect(() => () => validityRef.current?.(true), []);
function handleChange(nextText: string) {
setText(nextText);
const parsed = parse(nextText);
if (!parsed.ok) {
setError(parsed.error);
return;
}
setError(null);
if (!Object.is(parsed.value, value)) {
setSyncedValue(parsed.value);
onCommit(parsed.value);
}
}
function handleBlur() {
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
if (error === null) {
setText(format(value));
}
}
return (
<>
<input
aria-invalid={error ? true : undefined}
aria-label={ariaLabel}
className={error ? `${className} is-invalid` : className}
disabled={disabled}
inputMode={inputMode}
maxLength={maxLength}
onBlur={handleBlur}
onChange={(event) => handleChange(event.target.value)}
placeholder={placeholder}
title={error ?? undefined}
type="text"
value={text}
/>
<span className="estimate-print-value">
{printFormat ? printFormat(value) : error ? format(value) : text}
</span>
</>
);
}

View file

@ -0,0 +1,128 @@
"use client";
import { type FormEvent, useState } from "react";
import { FormModal } from "../components/form-modal";
import {
estimateDefaultGroups,
type EstimateGroupSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
import { estimateTextLimits } from "./estimate-balance-document";
export interface EstimateGroupFormValues {
costGroup: string;
title: string;
editor: string | null;
}
interface EstimateGroupModalProps {
group: EstimateGroupSnapshot | null;
onClose: () => void;
onSave: (values: EstimateGroupFormValues) => void;
}
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
const [title, setTitle] = useState(group?.title ?? "");
const [editor, setEditor] = useState(group?.editor ?? "");
function applyTemplate(index: string) {
const template = estimateDefaultGroups[Number(index)];
if (template) {
setCostGroup(template.costGroup);
setTitle(template.title);
}
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!title.trim()) {
return;
}
onSave({
costGroup: costGroup.trim(),
title: title.trim(),
editor: editor.trim() || null,
});
}
return (
<FormModal
isSaving={false}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!title.trim()}
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
>
<div className="row g-3">
{!group ? (
<div className="col-12">
<label className="form-label" htmlFor="estimate-group-template">
Aus Vorlage KG 400 übernehmen
</label>
<select
className="form-select"
defaultValue=""
id="estimate-group-template"
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– Vorlage wählen –</option>
{estimateDefaultGroups.map((template, index) => (
<option key={`${template.costGroup}-${template.title}`} value={index}>
{[template.costGroup, template.title].filter(Boolean).join(" ")}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="estimate-group-cost-group">
KG (DIN 276)
</label>
<input
className="form-control"
id="estimate-group-cost-group"
maxLength={estimateTextLimits.costGroup}
onChange={(event) => setCostGroup(event.target.value)}
placeholder="z. B. 440"
value={costGroup}
/>
</div>
<div className="col-12 col-md-9">
<label className="form-label" htmlFor="estimate-group-title">
Titel
</label>
<input
className="form-control"
id="estimate-group-title"
maxLength={estimateTextLimits.groupTitle}
onChange={(event) => setTitle(event.target.value)}
placeholder="z. B. Starkstromanlagen"
required
value={title}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="estimate-group-editor">
Bearbeitet von
</label>
<input
className="form-control"
id="estimate-group-editor"
maxLength={estimateTextLimits.groupEditor}
onChange={(event) => setEditor(event.target.value)}
placeholder="Gewerk oder Name (optional)"
value={editor}
/>
</div>
<div className="col-12">
<p className="text-secondary small mb-0">
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
detaillierten Bilanz keiner Kostengruppe zugeordnet.
</p>
</div>
</div>
</FormModal>
);
}

View file

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
import { formatIsoDateGerman } from "./estimate-balance-format";
interface EstimatePrintHeaderProps {
project: ProjectDto;
balance: EstimateBalanceDto;
}
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
/**
* Print-only title block and page footer of the rough power balance in the
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
* meta data, "Seite x von y". Hidden on screen.
*/
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
const [hasLogo, setHasLogo] = useState(false);
const [logoVersion, setLogoVersion] = useState("");
useEffect(() => {
getDocumentBranding()
.then((branding) => {
setOffice(branding.office);
setHasLogo(Boolean(branding.logo));
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
})
.catch(() => {
setOffice(null);
setHasLogo(false);
});
}, []);
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
const footer = `
@page estimate-balance {
size: A4 landscape;
margin: 10mm 10mm 13mm;
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }
}`;
return (
<>
<style>{footer}</style>
<header className="estimate-print-titleblock" aria-hidden="true">
<div className="estimate-print-logo">
{hasLogo ? (
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
) : (
<span>{office?.officeName ?? ""}</span>
)}
</div>
<div className="estimate-print-title">
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
<div className="estimate-print-heading">
<span className="estimate-print-stand">{balance.name}</span>
<span className="estimate-print-project">{project.name}</span>
</div>
</div>
<dl className="estimate-print-meta">
<div>
<dt>Projekt-Nr.</dt>
<dd>{project.internalProjectNumber ?? "–"}</dd>
</div>
<div>
<dt>Bauherr</dt>
<dd>{project.buildingOwner ?? "–"}</dd>
</div>
<div>
<dt>Anschrift</dt>
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
</div>
</dl>
<dl className="estimate-print-meta is-tinted">
<div>
<dt>Leistungsphase</dt>
<dd>{balance.workStage ?? "–"}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{date ?? "–"}</dd>
</div>
<div>
<dt>Bearbeitung</dt>
<dd>{project.projectEditor ?? "–"}</dd>
</div>
<div>
<dt>Büro</dt>
<dd>{office?.officeName ?? "–"}</dd>
</div>
</dl>
</header>
</>
);
}

View file

@ -0,0 +1,191 @@
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
import type {
CircuitTreeCircuitDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../types";
/**
* Pure read model for the print view (cover page, board overview and board
* plans). It only derives display values from the existing circuit-tree
* responses and never changes project data.
*/
export interface PrintBoardSummary {
board: DistributionBoardDto;
circuitListId: string;
tree: CircuitTreeResponseDto;
floorName: string | null;
circuitCount: number;
/** Σ quantity × power per unit, without any simultaneity factor. */
installedPowerKw: number;
/** Σ row totals, i.e. with the device-row simultaneity factors. */
devicePowerKw: number;
simultaneityFactor: number;
/** Device power with the board simultaneity factor applied. */
demandPowerKw: number;
/**
* Symmetrical three-phase design current from the apparent power
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
*/
designCurrentA: number;
}
export function summarizePrintBoard(input: {
board: DistributionBoardDto;
circuitListId: string;
tree: CircuitTreeResponseDto;
floorName: string | null;
}): PrintBoardSummary {
const { tree } = input;
let circuitCount = 0;
let installedPowerKw = 0;
let apparentPowerKva = 0;
for (const section of tree.sections) {
for (const circuit of section.circuits) {
circuitCount += 1;
for (const row of circuit.deviceRows) {
installedPowerKw += row.quantity * row.powerPerUnit;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
}
}
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
return {
...input,
circuitCount,
installedPowerKw,
devicePowerKw: tree.distributionBoardTotalPower,
simultaneityFactor,
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
designCurrentA:
(apparentPowerKva * simultaneityFactor * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV),
};
}
export interface PrintBoardHierarchyEntry {
summary: PrintBoardSummary;
depth: number;
parentBoardId: string | null;
/** Own demand plus the plain sum of all resolved downstream boards. */
cumulatedDemandPowerKw: number;
}
export function assertPrintRevisionConsistency(
revision: number,
summaries: readonly PrintBoardSummary[],
finalRevision: number
) {
if (finalRevision !== revision || summaries.some(({ tree }) => tree.currentRevision !== revision)) {
throw new Error("Das Projekt wurde während des Ladens geändert. Bitte die Seitenansicht neu laden.");
}
}
/**
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
* decided. A board is shown below another board only when its `fedFrom`
* text equals that board's number or name (case-insensitive). The
* cumulated value is a plain sum without an upstream simultaneity factor.
* Unresolved texts and cycles keep a board at the top level.
*/
export function buildPreliminaryBoardHierarchy(
summaries: PrintBoardSummary[]
): PrintBoardHierarchyEntry[] {
const normalize = (value: string | null) =>
value?.trim().toLocaleLowerCase("de") || null;
const parentById = new Map<string, string | null>();
for (const summary of summaries) {
const fedFrom = normalize(summary.board.fedFrom);
const candidates = fedFrom
? summaries.filter(
(candidate) =>
candidate !== summary &&
(normalize(candidate.board.boardNumber) === fedFrom ||
normalize(candidate.board.name) === fedFrom)
)
: [];
const parent = candidates.length === 1 ? candidates[0] : undefined;
parentById.set(summary.board.id, parent?.board.id ?? null);
}
for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]);
let current = parentById.get(summary.board.id) ?? null;
while (current !== null) {
if (visited.has(current)) {
parentById.set(summary.board.id, null);
break;
}
visited.add(current);
current = parentById.get(current) ?? null;
}
}
const childrenOf = (boardId: string | null) =>
summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId
);
const cumulated = (summary: PrintBoardSummary): number =>
summary.demandPowerKw +
childrenOf(summary.board.id).reduce(
(sum, child) => sum + cumulated(child),
0
);
const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({
summary,
depth,
parentBoardId: parentById.get(summary.board.id) ?? null,
cumulatedDemandPowerKw: cumulated(summary),
});
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
};
childrenOf(null).forEach((root) => walk(root, 0));
return entries;
}
const kilowattFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const unitPowerFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
const currentFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
export const formatKilowatt = (value: number) => kilowattFormat.format(value);
export const formatUnitPower = (value: number) => unitPowerFormat.format(value);
export const formatFactor = (value: number) => kilowattFormat.format(value);
export const formatCurrent = (value: number) => currentFormat.format(value);
export function formatProtectionDevice(
protection: CircuitTreeProtectionDeviceDto | undefined
): string | null {
if (!protection) {
return null;
}
const parts = [
protectionDeviceTypeLabels[protection.type],
protection.tripCharacteristic,
`${protection.ratedCurrentA} A`,
protection.fuseUtilizationCategory,
].filter(Boolean);
let text = parts.join(" ");
if (protection.ratedResidualCurrentMa !== undefined) {
text += `, ${protection.ratedResidualCurrentMa} mA`;
if (protection.rcdType) {
text += ` Typ ${protection.rcdType}`;
}
}
return text;
}
export function formatCircuitCable(circuit: CircuitTreeCircuitDto): string | null {
return [circuit.cableType, circuit.cableCrossSection].filter(Boolean).join(" · ") || null;
}

View file

@ -0,0 +1,481 @@
/* Print view (Seitenansicht) in the kbp corporate design. Colors are
derived from the kbp logo and presentation material; change them only
here.
All selectors are scoped to .print-view. The @page rules (format, margins,
page numbers) are injected by the print page because they need project
data. */
.print-view {
--pd-blue: #476088;
--pd-lilac: #9198c4;
--pd-line: #d6dae5;
--pd-tint: #f5f6fa;
--pd-text: #2b2b2b;
--pd-muted: #6b7280;
--pd-accent: #c9782a;
--pd-font: Arial, "Helvetica Neue", Helvetica, sans-serif;
--pd-landscape-width: 297mm;
--pd-landscape-height: 210mm;
min-height: 100vh;
background: #e7e9ef;
color: var(--pd-text);
font-family: var(--pd-font);
color-scheme: light;
}
.print-view.pd-a3 {
--pd-landscape-width: 420mm;
--pd-landscape-height: 297mm;
}
.pd-toolbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid var(--pd-line);
font-size: 14px;
}
.pd-toolbar-title {
margin-right: auto;
color: var(--pd-blue);
font-weight: bold;
}
.pd-toolbar .pd-toolbar-message {
color: var(--pd-muted);
font-size: 13px;
}
.pd-toolbar .pd-toolbar-error {
color: #b04035;
font-size: 13px;
}
.pd-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 24px 16px 48px;
overflow-x: auto;
}
.pd-sheet {
position: relative;
flex: none;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
font-size: 9pt;
line-height: 1.3;
}
.pd-portrait {
width: 210mm;
min-height: 297mm;
padding: 18mm 18mm 22mm;
page: lb-portrait;
}
.pd-landscape {
width: var(--pd-landscape-width);
min-height: var(--pd-landscape-height);
padding: 12mm 12mm 18mm;
page: lb-landscape;
}
.pd-a3 .pd-landscape {
font-size: 10pt;
}
.print-view table {
border-collapse: collapse;
width: 100%;
}
.print-view caption {
caption-side: top;
padding: 0 0 1.5mm;
color: var(--pd-blue);
font-size: 10pt;
font-weight: bold;
letter-spacing: 0.04em;
text-align: left;
text-transform: uppercase;
}
.print-view sub {
font-size: 0.75em;
}
.pd-r {
text-align: right !important;
}
.pd-placeholder {
color: var(--pd-accent);
font-style: italic;
}
.pd-open {
color: var(--pd-accent);
font-weight: bold;
}
.pd-multiline {
white-space: pre-line;
}
.pd-logo {
display: block;
max-width: 100%;
object-fit: contain;
}
.pd-logo-missing {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--pd-line);
color: var(--pd-muted);
font-size: 8pt;
}
/* Cover */
.pd-cover-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid var(--pd-blue);
padding-bottom: 6mm;
}
.pd-cover-kicker {
margin-top: 3mm;
color: var(--pd-muted);
font-size: 10pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pd-cover-logo {
height: 15mm;
width: auto;
max-width: 60mm;
}
span.pd-cover-logo {
width: 40mm;
}
.pd-cover-title {
margin: 14mm 0 10mm;
}
.pd-cover-title h1 {
margin: 0;
color: var(--pd-blue);
font-size: 30pt;
font-weight: bold;
}
.pd-cover-title p {
margin: 2mm 0 0;
color: var(--pd-muted);
font-size: 13pt;
}
.pd-kv {
margin-bottom: 7mm;
}
.pd-kv th,
.pd-kv td {
border: 1px solid var(--pd-line);
padding: 2mm 3mm;
font-size: 9.5pt;
text-align: left;
vertical-align: top;
}
.pd-kv th {
width: 42mm;
background: var(--pd-tint);
color: var(--pd-muted);
font-weight: normal;
}
.pd-cover-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6mm;
}
.pd-cover-cols .pd-kv th {
width: 30mm;
}
.pd-block {
margin-bottom: 7mm;
}
.pd-col-index {
width: 14mm;
}
/* Generic grid tables */
.pd-grid th,
.pd-grid td {
border: 1px solid var(--pd-line);
padding: 1.3mm 2mm;
text-align: left;
vertical-align: top;
}
.pd-grid > thead > tr > th:not(.pd-tb-cell) {
background: var(--pd-blue);
border-color: var(--pd-blue);
color: #fff;
font-weight: bold;
vertical-align: bottom;
}
/* Footer (screen only; printed pages use @page margin boxes) */
.pd-sheet-footer {
position: absolute;
left: 12mm;
right: 12mm;
bottom: 7mm;
display: flex;
justify-content: space-between;
border-top: 1px solid var(--pd-line);
padding-top: 2mm;
color: var(--pd-muted);
font-size: 7.5pt;
}
.pd-portrait .pd-sheet-footer {
left: 18mm;
right: 18mm;
bottom: 10mm;
}
/* Title block */
.pd-grid th.pd-tb-cell {
border: none;
padding: 0 0 4mm;
background: none;
font-weight: normal;
}
.pd-titleblock {
display: grid;
grid-template-columns: 28mm 1fr auto auto;
border: 1.5px solid var(--pd-blue);
margin-bottom: 4mm;
background: #fff;
font-size: 9pt;
}
.pd-grid .pd-titleblock {
margin-bottom: 0;
}
.pd-titleblock > * {
padding: 2.5mm 3.5mm;
border-left: 1px solid var(--pd-line);
}
.pd-titleblock > :first-child {
border-left: none;
}
.pd-tb-logo {
display: flex;
align-items: center;
justify-content: center;
height: 17mm;
}
.pd-tb-logo-image {
max-height: 10mm;
}
span.pd-tb-logo-image {
width: 100%;
height: 10mm;
}
.pd-tb-kicker {
color: var(--pd-muted);
font-size: 7.5pt;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.pd-tb-title {
display: flex;
align-items: baseline;
gap: 4mm;
margin-top: 1mm;
}
.pd-tb-number {
color: var(--pd-blue);
font-size: 18pt;
font-weight: bold;
}
.pd-tb-name {
font-size: 14pt;
font-weight: bold;
}
.pd-tb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 0.8mm 4mm;
align-content: center;
margin: 0;
font-size: 8pt;
}
.pd-tb-grid div {
display: contents;
}
.pd-tb-grid dt {
color: var(--pd-muted);
font-weight: normal;
}
.pd-tb-grid dd {
margin: 0;
font-weight: bold;
}
.pd-tb-meta {
background: var(--pd-tint);
}
/* Board plan table */
.pd-plan {
font-size: 7.6pt;
line-height: 1.2;
}
.pd-a3 .pd-plan {
font-size: 9pt;
}
.pd-plan th,
.pd-plan td {
padding: 0.75mm 1.6mm;
}
.pd-plan td.pd-bmk {
color: var(--pd-blue);
font-weight: bold;
white-space: nowrap;
}
.pd-plan tr.pd-group td {
background: var(--pd-tint);
border-top: 1.5px solid var(--pd-lilac);
font-weight: bold;
}
.pd-plan tr.pd-component td,
.pd-plan tr.pd-component td.pd-bmk,
.pd-plan tr.pd-device td {
color: var(--pd-muted);
}
.pd-plan tr.pd-device td:nth-child(2) {
padding-left: 6mm;
}
.pd-plan tr.pd-reserve td {
color: var(--pd-muted);
font-style: italic;
}
.pd-plan tr.pd-subtotal td {
border-bottom: 1.5px solid var(--pd-line);
font-weight: bold;
}
.pd-plan .pd-sub {
color: var(--pd-muted);
font-size: 0.9em;
}
.pd-plan tfoot td {
background: var(--pd-tint);
font-weight: bold;
}
.pd-plan tfoot tr.pd-grand td {
background: var(--pd-blue);
border-color: var(--pd-blue);
color: #fff;
}
.pd-plan tr {
break-inside: avoid;
}
/* Overview */
.pd-overview {
display: grid;
grid-template-columns: 62mm 1fr;
gap: 6mm;
}
.pd-a3 .pd-overview {
grid-template-columns: 90mm 1fr;
}
.pd-structure h2 {
margin: 0 0 3mm;
color: var(--pd-blue);
font-size: 10pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pd-tree,
.pd-tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.pd-tree ul {
margin-left: 5mm;
padding-left: 4mm;
border-left: 1.5px solid var(--pd-lilac);
}
.pd-tree li {
position: relative;
margin: 2.5mm 0;
}
.pd-tree ul > li::before {
content: "";
position: absolute;
left: -4mm;
top: 5mm;
width: 4mm;
border-top: 1.5px solid var(--pd-lilac);
}
.pd-node {
display: grid;
gap: 0.3mm;
padding: 1.5mm 2.5mm;
border: 1px solid var(--pd-line);
border-left: 3px solid var(--pd-blue);
background: #fff;
}
.pd-node.pd-feed {
border-left-color: var(--pd-accent);
}
.pd-node span {
color: var(--pd-muted);
font-size: 8pt;
}
.pd-node em {
color: var(--pd-blue);
font-size: 8.5pt;
font-style: normal;
font-weight: bold;
}
.pd-note {
margin: 4mm 0 1mm;
padding: 2.5mm 3mm;
border-left: 3px solid var(--pd-accent);
background: #fbf4ec;
font-size: 8pt;
}
.pd-legend {
margin: 1mm 0 0;
color: var(--pd-muted);
font-size: 7.5pt;
}
@media print {
html,
body,
.print-view {
background: #fff !important;
}
/* The dark theme sets color-scheme: dark on the root; Chrome then paints
the page margins dark. Printing always uses the light scheme. */
html:has(.print-view) {
color-scheme: light !important;
}
.pd-toolbar,
.pd-sheet-footer {
display: none !important;
}
.pd-pages {
display: block;
padding: 0;
overflow: visible;
}
.pd-sheet {
width: auto;
min-height: 0;
padding: 0;
box-shadow: none;
break-after: page;
}
.pd-sheet:last-child {
break-after: auto;
}
.print-view * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

View file

@ -0,0 +1,625 @@
"use client";
import type { ReactNode } from "react";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
OfficeProfileDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../types";
import {
formatCircuitCable,
formatCurrent,
formatFactor,
formatKilowatt,
formatProtectionDevice,
formatUnitPower,
type PrintBoardHierarchyEntry,
} from "./print-document-model";
export interface PrintDocumentProps {
project: ProjectDto;
office: OfficeProfileDto;
entries: PrintBoardHierarchyEntry[];
namedSnapshots: ProjectSnapshotMetadataDto[];
logoSrc: string | null;
printedAt: Date;
}
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
if (!value) {
return <Placeholder />;
}
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
}
const dateFormat = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function Placeholder({ label = "ergänzen" }: { label?: string }) {
return <span className="pd-placeholder">[{label}]</span>;
}
function Open() {
return <span className="pd-open">offen</span>;
}
function Logo({ src, className }: { src: string | null; className: string }) {
return src ? (
// eslint-disable-next-line @next/next/no-img-element
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
) : (
<span className={`pd-logo-missing ${className}`}>Logo</span>
);
}
function SheetFooter({ project, stand }: { project: ProjectDto; stand: string }) {
return (
<div className="pd-sheet-footer" aria-hidden="true">
<span>{project.name} · Leistungsbilanz</span>
<span>
Stand {stand} · Datenstand Rev. {project.currentRevision}
</span>
</div>
);
}
function TitleBlock({
kicker,
number,
name,
rows,
project,
stand,
logoSrc,
}: {
kicker: string;
number?: string | null;
name: string;
rows: Array<[ReactNode, ReactNode]>;
project: ProjectDto;
stand: string;
logoSrc: string | null;
}) {
return (
<div className="pd-titleblock">
<div className="pd-tb-logo">
<Logo className="pd-tb-logo-image" src={logoSrc} />
</div>
<div className="pd-tb-main">
<div className="pd-tb-kicker">{kicker}</div>
<div className="pd-tb-title">
{number ? <span className="pd-tb-number">{number}</span> : null}
<span className="pd-tb-name">{name}</span>
</div>
</div>
<dl className="pd-tb-grid">
{rows.map(([label, value], index) => (
<div key={index}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<dl className="pd-tb-grid pd-tb-meta">
<div>
<dt>Projekt</dt>
<dd>{project.name}</dd>
</div>
<div>
<dt>Projekt-Nr.</dt>
<dd>{project.internalProjectNumber ?? "–"}</dd>
</div>
<div>
<dt>Stand</dt>
<dd>{stand}</dd>
</div>
<div>
<dt>Datenstand</dt>
<dd>Rev. {project.currentRevision}</dd>
</div>
</dl>
</div>
);
}
function boardLabel(entry: PrintBoardHierarchyEntry) {
const { board } = entry.summary;
return board.boardNumber ? `${board.boardNumber} – ${board.name}` : board.name;
}
function CoverSheet({
project,
office,
entries,
namedSnapshots,
logoSrc,
stand,
}: Omit<PrintDocumentProps, "printedAt"> & { stand: string }) {
return (
<section className="pd-sheet pd-portrait pd-cover">
<div className="pd-cover-head">
<div className="pd-cover-kicker">Projektdokumentation Elektrotechnik</div>
<Logo className="pd-cover-logo" src={logoSrc} />
</div>
<div className="pd-cover-title">
<h1>Leistungsbilanz</h1>
<p>Verteilerübersicht und Verteilerpläne</p>
</div>
<table className="pd-kv">
<caption>Projekt</caption>
<tbody>
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
</tbody>
</table>
<div className="pd-cover-cols">
<table className="pd-kv">
<caption>Planverfasser</caption>
<tbody>
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
</tbody>
</table>
<table className="pd-kv">
<caption>Dokument</caption>
<tbody>
<tr><th>Dokumentart</th><td>Leistungsbilanz</td></tr>
<tr><th>Stand</th><td>{stand}</td></tr>
<tr><th>Datenstand</th><td>Revision {project.currentRevision}</td></tr>
<tr><th>Verteiler</th><td>{entries.length}</td></tr>
</tbody>
</table>
</div>
<table className="pd-grid pd-block">
<caption>Planstände</caption>
<thead>
<tr><th className="pd-col-index">Index</th><th>Datum</th><th>Änderung</th><th>Bearbeitung</th></tr>
</thead>
<tbody>
{namedSnapshots.length ? (
namedSnapshots.map((snapshot, index) => (
<tr key={snapshot.id}>
<td>{String.fromCharCode(97 + (index % 26))}</td>
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
<td>{snapshot.name}</td>
{/* Snapshots do not record an editor yet; the project's
current editor is shown for every entry. */}
<td><Value value={project.projectEditor} /></td>
</tr>
))
) : (
<tr><td colSpan={4}>–</td></tr>
)}
</tbody>
</table>
<table className="pd-grid pd-block">
<caption>Inhalt</caption>
<tbody>
<tr><td className="pd-col-index">1</td><td>Verteilerübersicht und Leistungssummen</td></tr>
{entries.map((entry, index) => (
<tr key={entry.summary.board.id}>
<td className="pd-col-index">2.{index + 1}</td>
<td>Verteilerplan {boardLabel(entry)}</td>
</tr>
))}
</tbody>
</table>
<SheetFooter project={project} stand={stand} />
</section>
);
}
function StructureNode({
entry,
entries,
}: {
entry: PrintBoardHierarchyEntry;
entries: PrintBoardHierarchyEntry[];
}) {
const children = entries.filter(
(candidate) => candidate.parentBoardId === entry.summary.board.id
);
const { board } = entry.summary;
return (
<li>
<div className="pd-node">
<strong>{board.boardNumber ?? board.name}</strong>
{board.boardNumber ? <span>{board.name}</span> : null}
<em>{formatKilowatt(entry.summary.demandPowerKw)} kW</em>
</div>
{children.length ? (
<ul>
{children.map((child) => (
<StructureNode entries={entries} entry={child} key={child.summary.board.id} />
))}
</ul>
) : null}
</li>
);
}
function OverviewSheet({
project,
entries,
logoSrc,
stand,
}: {
project: ProjectDto;
entries: PrintBoardHierarchyEntry[];
logoSrc: string | null;
stand: string;
}) {
const roots = entries.filter((entry) => entry.parentBoardId === null);
// Top-level boards whose "fed from" text names no other board are grouped
// below that text as an external feed (e.g. "HAK / Zähleranlage").
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
for (const root of roots) {
const feed = root.summary.board.fedFrom ?? "";
feedGroups.set(feed, [...(feedGroups.get(feed) ?? []), root]);
}
const sum =(pick: (entry: PrintBoardHierarchyEntry) => number) =>
entries.reduce((total, entry) => total + pick(entry), 0);
return (
<section className="pd-sheet pd-landscape">
<TitleBlock
kicker="Übersicht"
logoSrc={logoSrc}
name="Verteilerübersicht und Leistungssummen"
project={project}
rows={[
["Verteiler", String(entries.length)],
["Netz", `${project.threePhaseVoltageV} / ${project.singlePhaseVoltageV} V`],
]}
stand={stand}
/>
<div className="pd-overview">
<div className="pd-structure">
<h2>Versorgungsstruktur</h2>
<ul className="pd-tree">
{[...feedGroups].map(([feed, feedRoots]) => {
const nodes = feedRoots.map((root) => (
<StructureNode entries={entries} entry={root} key={root.summary.board.id} />
));
return feed ? (
<li key={`feed:${feed}`}>
<div className="pd-node pd-feed">
<strong>Einspeisung</strong>
<span>{feed}</span>
</div>
<ul>{nodes}</ul>
</li>
) : (
nodes
);
})}
</ul>
</div>
<div>
<table className="pd-grid pd-plan">
<thead>
<tr>
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
<th className="pd-r">SK</th>
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
<th className="pd-r">GZF<br />Vert.</th>
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
<th className="pd-r">I<sub>B</sub><br />[A]</th>
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const { board } = entry.summary;
return (
<tr key={board.id}>
<td style={{ paddingLeft: `${1.6 + entry.depth * 3.5}mm` }}>
{entry.depth ? "└ " : ""}
<strong>{board.boardNumber ?? "–"}</strong>
</td>
<td>{board.name}</td>
<td>{board.location ?? "–"}</td>
<td>{entry.summary.floorName ?? "–"}</td>
<td>{board.supplyType ?? "–"}</td>
<td>{board.fedFrom ?? "–"}</td>
<td className="pd-r">{entry.summary.circuitCount}</td>
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
<td />
<td />
</tr>
</tfoot>
</table>
<p className="pd-note">
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
Verknüpfung und Summierung folgt.
</p>
<p className="pd-legend">
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
S aus cos φ je Gerätezeile
</p>
</div>
</div>
<SheetFooter project={project} stand={stand} />
</section>
);
}
const planColumnCount = 11;
function roomLabel(row: CircuitTreeDeviceRowDto) {
return row.roomNumberSnapshot
? `${row.roomNumberSnapshot} ${row.roomNameSnapshot ?? ""}`.trim()
: "–";
}
function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
return (
<tr className="pd-component">
<td className="pd-bmk">{component.equipmentIdentifier}</td>
<td colSpan={6}>{component.name}</td>
<td>{formatProtectionDevice(component.protectionDevice) ?? "–"}</td>
<td colSpan={3} />
</tr>
);
}
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
const rows = circuit.deviceRows;
const circuitCells = (
<>
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
<td>{formatCircuitCable(circuit) ?? (circuit.isReserve ? "–" : <Open />)}</td>
<td className="pd-r">
{circuit.cableLength ?? (circuit.isReserve ? "–" : <Open />)}
</td>
<td>{circuit.remark ?? ""}</td>
</>
);
if (rows.length === 0) {
return (
<tr className="pd-circuit pd-reserve">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName ?? "Reserve"}</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r">–</td>
{circuitCells}
</tr>
);
}
if (rows.length === 1) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;
return (
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>
{title}
{title !== row.displayName ? (
<span className="pd-sub"> · {row.displayName}</span>
) : null}
</td>
<td>{roomLabel(row)}</td>
<td className="pd-r">{row.quantity}</td>
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
);
}
return (
<>
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName ?? ""}</td>
<td /><td /><td /><td />
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
{rows.map((row) => (
<tr className="pd-device" key={row.id}>
<td />
<td>{row.displayName}</td>
<td>{roomLabel(row)}</td>
<td className="pd-r">{row.quantity}</td>
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
<td className="pd-r">{formatKilowatt(row.rowTotalPower)}</td>
<td colSpan={4} />
</tr>
))}
</>
);
}
function BoardPlanSheet({
entry,
project,
logoSrc,
stand,
}: {
entry: PrintBoardHierarchyEntry;
project: ProjectDto;
logoSrc: string | null;
stand: string;
}) {
const { summary } = entry;
const { board, tree } = summary;
return (
<section className="pd-sheet pd-landscape">
<table className="pd-grid pd-plan">
{/* The title block lives in <thead> so it repeats on every printed page. */}
<thead>
<tr>
<th className="pd-tb-cell" colSpan={planColumnCount}>
<TitleBlock
kicker="Verteilerplan"
logoSrc={logoSrc}
name={board.name}
number={board.boardNumber}
project={project}
rows={[
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
["Netzart", board.supplyType ?? "–"],
["Gespeist von", board.fedFrom ?? "–"],
[
<>P<sub>gl</sub></>,
<>
{formatKilowatt(summary.demandPowerKw)} kW · I<sub>B</sub>{" "}
{formatCurrent(summary.designCurrentA)} A
</>,
],
]}
stand={stand}
/>
</th>
</tr>
<tr className="pd-head">
<th>SK-Nr.</th><th>Bezeichnung</th><th>Raum</th>
<th className="pd-r">Anz.</th>
<th className="pd-r">P/Stk.<br />[kW]</th>
<th className="pd-r">GZF</th>
<th className="pd-r">P<br />[kW]</th>
<th>Schutz</th><th>Kabel</th>
<th className="pd-r">Länge<br />[m]</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
{tree.headerComponents.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
{tree.sections.map((section) => (
<SectionRows key={section.id} section={section} />
))}
{tree.footerComponents.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
</tbody>
<tfoot>
<tr>
<td />
<td colSpan={5}>
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
</td>
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
<td colSpan={4} />
</tr>
<tr>
<td />
<td colSpan={5}>Gleichzeitigkeitsfaktor Verteiler</td>
<td className="pd-r">{formatFactor(summary.simultaneityFactor)}</td>
<td colSpan={4} />
</tr>
<tr className="pd-grand">
<td />
<td colSpan={5}>Gleichzeitige Leistung P<sub>gl</sub></td>
<td className="pd-r">{formatKilowatt(summary.demandPowerKw)}</td>
<td colSpan={4}>I<sub>B</sub> ≈ {formatCurrent(summary.designCurrentA)} A</td>
</tr>
</tfoot>
</table>
<SheetFooter project={project} stand={stand} />
</section>
);
}
function SectionRows({
section,
}: {
section: PrintBoardHierarchyEntry["summary"]["tree"]["sections"][number];
}) {
return (
<>
<tr className="pd-group">
<td className="pd-bmk">{section.prefix}</td>
<td colSpan={planColumnCount - 1}>{section.displayName}</td>
</tr>
{section.components.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
{section.circuits.map((circuit) => (
<CircuitRows circuit={circuit} key={circuit.id} />
))}
<tr className="pd-subtotal">
<td />
<td colSpan={5}>Summe {section.displayName}</td>
<td className="pd-r">{formatKilowatt(section.sectionTotalPower)}</td>
<td colSpan={4} />
</tr>
</>
);
}
export function PrintDocument({
project,
office,
entries,
namedSnapshots,
logoSrc,
printedAt,
}: PrintDocumentProps) {
const stand = dateFormat.format(printedAt);
return (
<div className="pd-pages">
<CoverSheet
entries={entries}
logoSrc={logoSrc}
namedSnapshots={namedSnapshots}
office={office}
project={project}
stand={stand}
/>
<OverviewSheet entries={entries} logoSrc={logoSrc} project={project} stand={stand} />
{entries.map((entry) => (
<BoardPlanSheet
entry={entry}
key={entry.summary.board.id}
logoSrc={logoSrc}
project={project}
stand={stand}
/>
))}
</div>
);
}

View file

@ -93,9 +93,18 @@ export interface ProjectDto {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
siteAddress: string | null;
workStage: string | null;
projectLead: string | null;
projectEditor: string | null;
currentRevision: number;
}
export interface OfficeProfileDto {
officeName: string | null;
officeAddress: string | null;
}
export interface ProjectHistoryStateDto {
projectId: string;
currentRevision: number;
@ -172,7 +181,14 @@ export interface ProjectCommandResultDto {
history: ProjectHistoryStateDto;
}
export interface DistributionBoardDto {
export interface DistributionBoardInfoDto {
boardNumber: string | null;
location: string | null;
fedFrom: string | null;
remark: string | null;
}
export interface DistributionBoardDto extends DistributionBoardInfoDto {
id: string;
projectId: string;
name: string;
@ -181,6 +197,13 @@ export interface DistributionBoardDto {
simultaneityFactor: number;
}
export interface DocumentLogoMetadataDto {
mimeType: string;
fileName: string | null;
sizeBytes: number;
updatedAtIso: string;
}
export interface DistributionBoardCommandResultDto
extends ProjectCommandResultDto {
distributionBoard: DistributionBoardDto;
@ -203,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
@ -265,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 {
@ -493,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto {
}
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
// The document shapes are shared with the API; see
// src/shared/estimate-balance/estimate-balance.ts.
import type {
EstimateBalanceSnapshot,
EstimateComparison,
} from "../shared/estimate-balance/estimate-balance";
export type EstimateBalanceDto = EstimateBalanceSnapshot;
export type EstimateComparisonDto = EstimateComparison;
export type EstimateBalanceTemplate = "kg400" | "empty" | "copy";
export interface CreateEstimateBalanceInput {
name: string;
workStage?: string | null;
dateIso?: string | null;
template: EstimateBalanceTemplate;
/** Required for template "copy". */
copyFromBalanceId?: string;
}
export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto {
balance: EstimateBalanceDto;
}
/** PUT result: either a new revision or an unchanged document. */
export type EstimateBalanceUpdateResultDto =
| EstimateBalanceCommandResultDto
| { unchanged: true; balance: EstimateBalanceDto };

View file

@ -1,5 +1,7 @@
import type {
BuildingDto,
CircuitListDto,
CreateBuildingInput,
CreateFloorInput,
CreateProjectDeviceInput,
CreateRoomInput,
@ -8,12 +10,16 @@ import type {
DistributionBoardCommandResultDto,
DistributionBoardCopyCommandResultDto,
DistributionBoardDeleteCommandResultDto,
DistributionBoardInfoDto,
DocumentLogoMetadataDto,
FloorDto,
OfficeProfileDto,
GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
ProjectDeviceCommandResultDto,
ProjectDeviceDto,
ProjectBuildingCommandResultDto,
ProjectFloorCommandResultDto,
ProjectHistoryStateDto,
ProjectRevisionPageDto,
@ -32,6 +38,11 @@ import type {
ExternalInitialImportPlanDto,
ApplyExternalInitialImportInput,
ExternalInitialImportResultDto,
CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto,
EstimateBalanceDto,
EstimateBalanceUpdateResultDto,
EstimateComparisonDto,
} from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
@ -81,6 +92,30 @@ import type {
CircuitGroupSubtreeSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model";
/**
* Error of a failed API request. The message stays the server's German
* error text; status and optional error code allow callers to react to
* specific failures such as a revision conflict (409).
*/
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string | null;
constructor(message: string, status: number, code: string | null = null) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}
export function isRevisionConflictError(error: unknown) {
return (
error instanceof ApiRequestError &&
(error.status === 409 || error.code === "PROJECT_REVISION_CONFLICT")
);
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
@ -93,15 +128,23 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
if (!response.ok) {
let details = await response.text();
let code: string | null = null;
try {
const parsed = JSON.parse(details) as { error?: unknown };
const parsed = JSON.parse(details) as { error?: unknown; code?: unknown };
if (typeof parsed.error === "string") {
details = parsed.error;
}
if (typeof parsed.code === "string") {
code = parsed.code;
}
} catch {
// Keep non-JSON error responses unchanged.
}
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
throw new ApiRequestError(
details || `Anfrage fehlgeschlagen (Status ${response.status})`,
response.status,
code
);
}
if (response.status === 204) {
@ -256,7 +299,7 @@ export function updateProjectSettings(
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
}
} & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">>
) {
return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`,
@ -329,7 +372,7 @@ export function updateDistributionBoard(
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
},
} & Partial<DistributionBoardInfoDto>,
expectedRevision: number
) {
return request<DistributionBoardCommandResultDto>(
@ -341,6 +384,42 @@ export function updateDistributionBoard(
);
}
export const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() {
return request<{
logo: DocumentLogoMetadataDto | null;
office: OfficeProfileDto;
}>("/api/document-branding");
}
export function updateOfficeProfile(office: OfficeProfileDto) {
return request<{ office: OfficeProfileDto }>("/api/document-branding/office", {
method: "PUT",
body: JSON.stringify(office),
});
}
export async function uploadDocumentLogo(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return request<{ logo: DocumentLogoMetadataDto }>(documentLogoUrl, {
method: "PUT",
body: JSON.stringify({
fileName: file.name,
mimeType: file.type,
contentBase64: btoa(binary),
}),
});
}
export function deleteDocumentLogo() {
return request<void>(documentLogoUrl, { method: "DELETE" });
}
export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration`
@ -842,6 +921,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`);
}
@ -855,7 +989,7 @@ export function createFloor(
`/api/projects/${projectId}/floors`,
{
method: "POST",
body: JSON.stringify({ ...input, expectedRevision }),
body: JSON.stringify(toFloorRequestBody(input, expectedRevision)),
}
);
}
@ -868,7 +1002,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)),
});
}
@ -1038,3 +1172,66 @@ export function disconnectProjectDeviceRows(
}
);
}
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
function estimateBalancesUrl(projectId: string, balanceId?: string) {
const base = `/api/projects/${projectId}/estimate-balances`;
return balanceId ? `${base}/${balanceId}` : base;
}
export function listEstimateBalances(projectId: string) {
return request<EstimateBalanceDto[]>(estimateBalancesUrl(projectId));
}
export function getEstimateBalance(projectId: string, balanceId: string) {
return request<EstimateBalanceDto>(estimateBalancesUrl(projectId, balanceId));
}
export function createEstimateBalance(
projectId: string,
input: CreateEstimateBalanceInput,
expectedRevision: number
) {
return request<EstimateBalanceCommandResultDto>(estimateBalancesUrl(projectId), {
method: "POST",
body: JSON.stringify({ ...input, expectedRevision }),
});
}
/**
* Saves the complete target document. `keepalive` lets the request outlive
* the page, e.g. for the last autosave while the tab is being closed.
*/
export function updateEstimateBalance(
projectId: string,
balance: EstimateBalanceDto,
expectedRevision: number,
options: { keepalive?: boolean } = {}
) {
return request<EstimateBalanceUpdateResultDto>(
estimateBalancesUrl(projectId, balance.id),
{
method: "PUT",
body: JSON.stringify({ expectedRevision, balance }),
...(options.keepalive ? { keepalive: true } : {}),
}
);
}
export function deleteEstimateBalance(
projectId: string,
balanceId: string,
expectedRevision: number
) {
return request<ProjectCommandResultDto>(estimateBalancesUrl(projectId, balanceId), {
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
});
}
export function getEstimateComparison(projectId: string, balanceId: string) {
return request<EstimateComparisonDto>(
`${estimateBalancesUrl(projectId, balanceId)}/comparison`
);
}

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

View file

@ -0,0 +1,27 @@
/**
* Creates a random RFC 4122 version 4 UUID for new entities built in the
* browser.
*
* crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
* The app is also opened over plain HTTP from other machines in the LAN, where
* it is undefined, so fall back to crypto.getRandomValues(), which is
* available in every context.
*/
export function createClientId(
source: Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = crypto
): string {
if (typeof source.randomUUID === "function") {
return source.randomUUID();
}
const bytes = source.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}

View file

@ -0,0 +1,249 @@
import type {
DistributionBoardDto,
FloorDto,
ProjectDeviceDto,
} from "../types";
/**
* Pure filter logic for the project overview cards (Verteilungen,
* Projektgeräte). Every multi-select filter uses the PM semantics: an empty
* selection means "Alle", otherwise a record must match one selected value.
* Records without a value are addressed through the dedicated "none" ids.
*/
export interface FilterOption {
id: string;
label: string;
}
/** Option id for boards without floor ("Ohne Etage"). */
export const withoutFloorFilterId = "__without_floor__";
/** Option id for boards without supply type ("Nicht festgelegt"). */
export const withoutSupplyTypeFilterId = "__without_supply_type__";
/** Option id for project devices without cost group ("Ohne Kostengruppe"). */
export const withoutCostGroupFilterId = "__without_cost_group__";
export interface DistributionBoardFilters {
query: string;
floorIds: string[];
supplyTypes: string[];
}
export interface ProjectDeviceFilters {
query: string;
categories: string[];
phaseTypes: string[];
costGroups: string[];
}
export const emptyDistributionBoardFilters: DistributionBoardFilters = {
query: "",
floorIds: [],
supplyTypes: [],
};
export const emptyProjectDeviceFilters: ProjectDeviceFilters = {
query: "",
categories: [],
phaseTypes: [],
costGroups: [],
};
export function normalizeFilterQuery(query: string) {
return query.trim().toLocaleLowerCase("de");
}
function matchesQuery(
normalizedQuery: string,
values: readonly (string | null | undefined)[]
) {
return (
!normalizedQuery ||
values.some((value) =>
value?.toLocaleLowerCase("de").includes(normalizedQuery)
)
);
}
function matchesSelection(selected: readonly string[], value: string) {
return selected.length === 0 || selected.includes(value);
}
/** Adds or removes one value from a multi-select selection. */
export function toggleFilterValue(selected: readonly string[], value: string) {
return selected.includes(value)
? selected.filter((entry) => entry !== value)
: [...selected, value];
}
/**
* Drops selected values that are no longer offered (e.g. a deleted floor), so
* no invisible selection keeps hiding records.
*/
export function retainAvailableFilterValues(
selected: readonly string[],
options: readonly FilterOption[]
) {
const available = new Set(options.map((option) => option.id));
return selected.filter((value) => available.has(value));
}
export function isDistributionBoardFilterActive(
filters: DistributionBoardFilters
) {
return (
normalizeFilterQuery(filters.query) !== "" ||
filters.floorIds.length > 0 ||
filters.supplyTypes.length > 0
);
}
export function isProjectDeviceFilterActive(filters: ProjectDeviceFilters) {
return (
normalizeFilterQuery(filters.query) !== "" ||
filters.categories.length > 0 ||
filters.phaseTypes.length > 0 ||
filters.costGroups.length > 0
);
}
/** Floors in their configured order, followed by "Ohne Etage". */
export function buildFloorFilterOptions(
floors: readonly FloorDto[]
): FilterOption[] {
return [
...[...floors]
.sort(
(left, right) =>
left.sortOrder - right.sortOrder ||
left.name.localeCompare(right.name, "de", { numeric: true })
)
.map((floor) => ({ id: floor.id, label: floor.name })),
{ id: withoutFloorFilterId, label: "Ohne Etage" },
];
}
/**
* Supply types in catalog order: every enabled or used type, plus
* "Nicht festgelegt" when a board has no supply type.
*/
export function buildSupplyTypeFilterOptions(
catalog: readonly string[],
enabledSupplyTypes: readonly string[],
boards: readonly Pick<DistributionBoardDto, "supplyType">[],
labels: Readonly<Record<string, string>>
): FilterOption[] {
const offered = new Set<string>(enabledSupplyTypes);
let hasBoardWithoutSupplyType = false;
for (const board of boards) {
if (board.supplyType) {
offered.add(board.supplyType);
} else {
hasBoardWithoutSupplyType = true;
}
}
const options: FilterOption[] = catalog
.filter((supplyType) => offered.has(supplyType))
.map((supplyType) => ({
id: supplyType,
label: labels[supplyType] ?? supplyType,
}));
if (hasBoardWithoutSupplyType) {
options.push({ id: withoutSupplyTypeFilterId, label: "Nicht festgelegt" });
}
return options;
}
/**
* Board number and location come with the distribution-board info card
* (feature/print-view). They are optional here so the filter works with and
* without those fields and searches them as soon as the DTO carries them.
*/
export function filterDistributionBoards<
Board extends Pick<DistributionBoardDto, "name" | "floorId" | "supplyType"> & {
boardNumber?: string | null;
location?: string | null;
},
>(
boards: readonly Board[],
filters: DistributionBoardFilters,
floorNameById: ReadonlyMap<string, string>
): Board[] {
const query = normalizeFilterQuery(filters.query);
return boards.filter(
(board) =>
matchesQuery(query, [
board.name,
board.boardNumber,
board.location,
board.floorId ? floorNameById.get(board.floorId) : undefined,
]) &&
matchesSelection(
filters.floorIds,
board.floorId ?? withoutFloorFilterId
) &&
matchesSelection(
filters.supplyTypes,
board.supplyType ?? withoutSupplyTypeFilterId
)
);
}
function getCostGroupFilterValue(costGroup: string | null) {
const trimmed = costGroup?.trim() ?? "";
return trimmed || withoutCostGroupFilterId;
}
/** Distinct cost groups in natural order, followed by "Ohne Kostengruppe". */
export function buildCostGroupFilterOptions(
devices: readonly Pick<ProjectDeviceDto, "costGroup">[]
): FilterOption[] {
const costGroups = new Set<string>();
for (const device of devices) {
const value = getCostGroupFilterValue(device.costGroup);
if (value !== withoutCostGroupFilterId) {
costGroups.add(value);
}
}
return [
...[...costGroups]
.sort((left, right) => left.localeCompare(right, "de", { numeric: true }))
.map((costGroup) => ({ id: costGroup, label: costGroup })),
{ id: withoutCostGroupFilterId, label: "Ohne Kostengruppe" },
];
}
export function filterProjectDevices<
Device extends Pick<
ProjectDeviceDto,
| "name"
| "displayName"
| "category"
| "phaseType"
| "connectionKind"
| "costGroup"
>,
>(
devices: readonly Device[],
filters: ProjectDeviceFilters,
categoryLabels: Readonly<Record<string, string>> = {}
): Device[] {
const query = normalizeFilterQuery(filters.query);
return devices.filter(
(device) =>
matchesQuery(query, [
device.name,
device.displayName,
device.category,
categoryLabels[device.category],
device.connectionKind,
device.costGroup,
]) &&
matchesSelection(filters.categories, device.category) &&
matchesSelection(filters.phaseTypes, device.phaseType) &&
matchesSelection(
filters.costGroups,
getCostGroupFilterValue(device.costGroup)
)
);
}

View file

@ -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",
@ -65,6 +68,9 @@ const commandTypeLabels: Record<string, string> = {
"external-object.unassign-and-delete-created-row":
"Externes Objekt aus erzeugter Gerätezeile gelöst",
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
"estimate-balance.insert": "Grobe Leistungsbilanz angelegt",
"estimate-balance.update": "Grobe Leistungsbilanz bearbeitet",
"estimate-balance.delete": "Grobe Leistungsbilanz entfernt",
};
export function getProjectRevisionSourceLabel(