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

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
@ -10,9 +11,19 @@ export default function CircuitTreeEditPage() {
return (
<main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
<div className="d-flex align-items-center gap-2">
<Link
aria-label="Zurück zur Verteilerübersicht"
className="btn btn-outline-secondary btn-sm"
href={`/projects/${params.projectId}`}
title="Zurück zur Verteilerübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
</div>
<div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">

View file

@ -0,0 +1,745 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
Printer,
Redo2,
RefreshCw,
Undo2,
} from "lucide-react";
import {
ApiRequestError,
getEstimateBalance,
getEstimateComparison,
getProject,
getProjectHistory,
isRevisionConflictError,
redoProjectCommand,
undoProjectCommand,
updateEstimateBalance,
} from "../../../../../frontend/utils/api";
import type {
EstimateBalanceDto,
EstimateComparisonDto,
ProjectDto,
ProjectHistoryStateDto,
} from "../../../../../frontend/types";
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
import {
addEstimateGroup,
estimateBalanceFingerprint,
estimateTextLimits,
normalizeEstimateSortOrder,
toSaveableEstimateBalance,
updateEstimateGroup,
updateEstimateRow,
validateEstimateBalance,
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
import {
EstimateBalanceSheet,
type EstimateBalanceUpdate,
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
import {
EstimateGroupModal,
type EstimateGroupFormValues,
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
const AUTOSAVE_DELAY_MS = 800;
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
type GroupDialog = { group: EstimateGroupSnapshot | null };
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function isNotFoundError(error: unknown) {
return error instanceof ApiRequestError && error.status === 404;
}
export default function EstimateBalanceEditorPage() {
const params = useParams<{ projectId: string; balanceId: string }>();
const { projectId, balanceId } = params;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
const [saveError, setSaveError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMissing, setIsMissing] = useState(false);
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
() => new Set()
);
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
null
);
// Refs carry the latest values into timers, unload handlers and chained
// async saves without re-creating them.
const draftRef = useRef<EstimateBalanceDto | null>(null);
const revisionRef = useRef<number | null>(null);
const savedFingerprintRef = useRef<string | null>(null);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
const normalized = normalizeEstimateSortOrder(balance);
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
draftRef.current = normalized;
setDraft(normalized);
setSaveStatus("saved");
setSaveError(null);
}, []);
const reloadComparison = useCallback(async () => {
try {
setComparison(await getEstimateComparison(projectId, balanceId));
setComparisonError(null);
} catch (err) {
setComparisonError(
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
);
}
}, [balanceId, projectId]);
/** Reloads project, document and history; replaces the local draft. */
const reloadAll = useCallback(async () => {
const balanceRequest = getEstimateBalance(projectId, balanceId);
// Handled below; avoids an "unhandled rejection" while project/history load.
balanceRequest.catch(() => undefined);
// Project and history are applied even when the balance is missing, so
// Redo stays available after its creation was undone.
const [currentProject, currentHistory] = await Promise.all([
getProject(projectId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
setHistory(currentHistory);
const balance = await balanceRequest;
applyServerBalance(balance);
setIsMissing(false);
void reloadComparison();
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
const handleReloadError = useCallback((err: unknown) => {
if (isNotFoundError(err)) {
setIsMissing(true);
setDraft(null);
draftRef.current = null;
return;
}
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
}, []);
useEffect(() => {
if (!projectId || !balanceId) {
return;
}
setIsLoading(true);
reloadAll()
.then(() => setError(null))
.catch(handleReloadError)
.finally(() => setIsLoading(false));
}, [balanceId, handleReloadError, projectId, reloadAll]);
const clearSaveTimer = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}, []);
/**
* Saves the current draft if it differs from the last saved state. Saves
* never overlap: a save requested while another runs waits for it and then
* saves whatever changed in the meantime. Resolves to false when the draft
* could not be saved.
*/
const saveNow = useCallback(async (): Promise<boolean> => {
clearSaveTimer();
while (saveInFlightRef.current) {
await saveInFlightRef.current;
}
const current = draftRef.current;
const revision = revisionRef.current;
if (!current || revision === null) {
return true;
}
const fingerprint = estimateBalanceFingerprint(current);
if (fingerprint === savedFingerprintRef.current) {
setSaveStatus("saved");
return true;
}
const problems = validateEstimateBalance(current);
if (problems.length) {
setSaveStatus("invalid");
setSaveError(problems.join(" "));
return false;
}
const body = toSaveableEstimateBalance(current);
/**
* Another command changed the project revision. When this document is
* still unchanged on the server (e.g. an edit in a circuit list), the
* draft is kept and saved again; otherwise the server state wins.
*/
async function resolveConflict(): Promise<"retry" | "reloaded"> {
const [currentProject, serverBalance, currentHistory] = await Promise.all([
getProject(projectId),
getEstimateBalance(projectId, balanceId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
setHistory(currentHistory);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
return "retry";
}
applyServerBalance(serverBalance);
setNotice(CONFLICT_MESSAGE);
void reloadComparison();
return "reloaded";
}
const run = (async () => {
setSaveStatus("saving");
for (let attempt = 0; ; attempt += 1) {
try {
const result = await updateEstimateBalance(
projectId,
body,
attempt === 0 ? revision : revisionRef.current ?? revision
);
if (!("unchanged" in result)) {
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
setProject((currentProject) =>
currentProject
? { ...currentProject, currentRevision: result.history.currentRevision }
: currentProject
);
}
// The sent document is the saved state; the local draft is kept so
// typing is never interrupted by the server's copy.
savedFingerprintRef.current = fingerprint;
setSaveError(null);
const latest = draftRef.current;
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
setSaveStatus("pending");
} else {
setSaveStatus("saved");
}
void reloadComparison();
return true;
} catch (err) {
if (isRevisionConflictError(err)) {
try {
if (attempt === 0 && (await resolveConflict()) === "retry") {
continue;
}
if (attempt > 0) {
await reloadAll();
setNotice(CONFLICT_MESSAGE);
}
} catch (reloadError) {
handleReloadError(reloadError);
}
return false;
}
if (isNotFoundError(err)) {
handleReloadError(err);
return false;
}
setSaveStatus("error");
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
return false;
}
}
})();
saveInFlightRef.current = run;
try {
return await run;
} finally {
saveInFlightRef.current = null;
}
}, [
applyServerBalance,
balanceId,
clearSaveTimer,
handleReloadError,
projectId,
reloadAll,
reloadComparison,
]);
// Debounced autosave after every change of the draft.
useEffect(() => {
if (!draft) {
return;
}
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
return;
}
setSaveStatus((status) => (status === "saving" ? status : "pending"));
clearSaveTimer();
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
void saveNow();
}, AUTOSAVE_DELAY_MS);
return clearSaveTimer;
}, [clearSaveTimer, draft, saveNow]);
// Save immediately when the tab is hidden; on unload/unmount send the last
// change with keepalive so it survives the page.
useEffect(() => {
function hasUnsavedDraft() {
const current = draftRef.current;
return Boolean(
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
);
}
function sendKeepalive(): boolean {
const current = draftRef.current;
const revision = revisionRef.current;
if (
!current ||
revision === null ||
saveInFlightRef.current ||
validateEstimateBalance(current).length
) {
return false;
}
savedFingerprintRef.current = estimateBalanceFingerprint(current);
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
keepalive: true,
}).catch(() => undefined);
return true;
}
function handleVisibilityChange() {
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
void saveNow();
}
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedDraft() || sendKeepalive()) {
return;
}
// A save is still running or the draft is invalid: let the browser ask.
event.preventDefault();
event.returnValue = "";
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("beforeunload", handleBeforeUnload);
// In-app navigation (e.g. sidebar) unmounts the editor.
if (hasUnsavedDraft()) {
sendKeepalive();
}
};
}, [projectId, saveNow]);
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
setNotice(null);
setDraft((current) => (current ? update(current) : current));
}, []);
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
setInvalidFieldKeys((current) => {
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
return current;
}
const next = new Set(current);
if (isValid) {
next.delete(fieldKey);
} else {
next.add(fieldKey);
}
return next;
});
}, []);
async function handleHistory(direction: "undo" | "redo") {
setError(null);
setNotice(null);
setIsHistoryBusy(true);
try {
// Pending edits are saved first so they become their own undo step.
await saveNow();
const revision = revisionRef.current;
if (revision === null) {
return;
}
const result =
direction === "undo"
? await undoProjectCommand(projectId, revision)
: await redoProjectCommand(projectId, revision);
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
await reloadAll();
} catch (err) {
if (isRevisionConflictError(err)) {
await reloadAll().catch(handleReloadError);
setNotice(CONFLICT_MESSAGE);
} else if (isNotFoundError(err)) {
handleReloadError(err);
} else {
setError(
errorMessage(
err,
direction === "undo"
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
: "Die letzte Änderung konnte nicht wiederholt werden."
)
);
}
} finally {
setIsHistoryBusy(false);
}
}
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
const current = draftRef.current;
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
return;
}
event.preventDefault();
await saveNow();
router.push(`/projects/${projectId}/grobbilanz`);
}
function handleSaveGroup(values: EstimateGroupFormValues) {
const editingGroup = groupDialog?.group ?? null;
handleChange((current) =>
editingGroup
? updateEstimateGroup(current, editingGroup.id, values)
: addEstimateGroup(current, values).balance
);
setGroupDialog(null);
}
function handleSelectGzf(value: number) {
if (gzfTarget) {
handleChange((current) =>
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
simultaneityFactor: value,
})
);
}
setGzfTarget(null);
}
const isEditingDisabled = !draft || isHistoryBusy;
const invalidCount = invalidFieldKeys.size;
const listHref = `/projects/${projectId}/grobbilanz`;
return (
<main className="container-fluid py-4 estimate-print-root">
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
<div className="page-header estimate-page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Übersicht der Stände"
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
href={listHref}
onClick={(event) => void handleBackClick(event)}
title="Zurück zur Übersicht der Stände"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
<p className="text-secondary mb-0 estimate-no-print">
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
</p>
</div>
</div>
<div className="estimate-toolbar estimate-no-print">
<SaveStatusBadge
error={saveError}
invalidCount={invalidCount}
onRetry={() => void saveNow()}
status={saveStatus}
/>
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
<button
aria-label="Rückgängig"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.undoDepth === 0}
onClick={() => void handleHistory("undo")}
title="Rückgängig (letzte Änderung im Projekt)"
type="button"
>
<Undo2 aria-hidden="true" size={16} />
</button>
<button
aria-label="Wiederholen"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.redoDepth === 0}
onClick={() => void handleHistory("redo")}
title="Wiederholen (im Projekt)"
type="button"
>
<Redo2 aria-hidden="true" size={16} />
</button>
</div>
<button
aria-label="Drucken"
className="btn btn-sm btn-outline-secondary"
disabled={!draft}
onClick={() => window.print()}
title="Drucken"
type="button"
>
<Printer aria-hidden="true" className="me-1" size={16} />
Drucken
</button>
</div>
</div>
{notice ? (
<div className="alert alert-info estimate-no-print" role="status">
{notice}
</div>
) : null}
{error ? (
<div className="alert alert-warning estimate-no-print" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanz wird geladen …
</p>
) : null}
{isMissing ? (
<div className="alert alert-warning" role="alert">
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
rückgängig gemacht wurde.{" "}
<Link href={listHref}>Zur Übersicht der Stände</Link>
</div>
) : null}
{draft && project ? (
<>
<section className="card shadow-sm mb-3 estimate-sheet-card">
<div className="card-body">
<div className="estimate-header-fields">
<div className="estimate-header-field">
<span className="estimate-header-label">Projekt</span>
<span className="estimate-header-value">{project.name}</span>
</div>
<label className="estimate-header-field is-wide">
<span className="estimate-header-label">Stand</span>
<DraftInput
ariaLabel="Bezeichnung des Stands"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
format={(value: string) => value}
maxLength={estimateTextLimits.balanceName}
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
onValidityChange={(isValid) =>
handleValidityChange("balance:name", isValid)
}
parse={(text) =>
text.trim()
? { ok: true, value: text }
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
}
value={draft.name}
/>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">LPH</span>
<input
aria-label="Leistungsphase"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
maxLength={estimateTextLimits.workStage}
onChange={(event) =>
handleChange((current) => ({
...current,
workStage: event.target.value,
}))
}
placeholder="z. B. LPH 3"
type="text"
value={draft.workStage ?? ""}
/>
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">Datum</span>
<input
aria-label="Datum"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
onChange={(event) =>
handleChange((current) => ({
...current,
dateIso: event.target.value || null,
}))
}
type="date"
value={draft.dateIso ?? ""}
/>
<span className="estimate-print-value">
{formatIsoDateGerman(draft.dateIso)}
</span>
</label>
</div>
<EstimateBalanceSheet
balance={draft}
disabled={isEditingDisabled}
onAddGroup={() => setGroupDialog({ group: null })}
onChange={handleChange}
onEditGroup={(group) => setGroupDialog({ group })}
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
onValidityChange={handleValidityChange}
threePhaseVoltageV={project.threePhaseVoltageV}
/>
</div>
</section>
<section
className={
isComparisonOpen
? "card shadow-sm estimate-comparison-card"
: "card shadow-sm estimate-comparison-card estimate-no-print"
}
>
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<button
aria-controls="estimate-comparison-body"
aria-expanded={isComparisonOpen}
className="estimate-collapse-toggle"
onClick={() => setIsComparisonOpen((open) => !open)}
type="button"
>
{isComparisonOpen ? (
<ChevronDown aria-hidden="true" size={16} />
) : (
<ChevronRight aria-hidden="true" size={16} />
)}
Vergleich mit detaillierter Bilanz
</button>
{isComparisonOpen ? (
<button
aria-label="Vergleich neu laden"
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
onClick={() => void reloadComparison()}
title="Vergleich neu laden"
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
) : null}
</div>
{isComparisonOpen ? (
<div className="card-body" id="estimate-comparison-body">
{comparisonError ? (
<div className="alert alert-warning" role="alert">
{comparisonError}
</div>
) : null}
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
<p className="text-secondary small mt-2 mb-0">
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
Verteiler-GZF.
</p>
</div>
) : null}
</section>
</>
) : null}
{groupDialog ? (
<EstimateGroupModal
group={groupDialog.group}
onClose={() => setGroupDialog(null)}
onSave={handleSaveGroup}
/>
) : null}
{gzfTarget ? (
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
) : null}
</main>
);
}
function SaveStatusBadge({
error,
invalidCount,
onRetry,
status,
}: {
error: string | null;
invalidCount: number;
onRetry: () => void;
status: SaveStatus;
}) {
const label =
status === "saving"
? "Wird gespeichert …"
: status === "pending"
? "Ungespeicherte Änderungen"
: status === "error"
? "Nicht gespeichert – Fehler"
: status === "invalid"
? "Nicht gespeichert – ungültige Eingaben"
: "Gespeichert";
const tone =
status === "error" || status === "invalid"
? "is-error"
: status === "saved"
? "is-saved"
: "is-busy";
return (
<div className="estimate-save-status" role="status" aria-live="polite">
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
{label}
</span>
{invalidCount ? (
<span className="estimate-save-hint">
{invalidCount === 1
? "1 ungültige Eingabe wird nicht gespeichert"
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
</span>
) : null}
{status === "error" ? (
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
Erneut versuchen
</button>
) : null}
</div>
);
}

View file

@ -0,0 +1,292 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
import {
createEstimateBalance,
deleteEstimateBalance,
getProject,
listEstimateBalances,
} from "../../../../frontend/utils/api";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
ProjectCommandResultDto,
ProjectDto,
} from "../../../../frontend/types";
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
import {
EstimateBalanceCreateModal,
type EstimateBalanceCreateDefaults,
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
import {
formatIsoDateGerman,
formatKw,
} from "../../../../frontend/estimate-balance/estimate-balance-format";
function countRows(balance: EstimateBalanceDto) {
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
}
export default function EstimateBalanceListPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
const [createDefaults, setCreateDefaults] =
useState<EstimateBalanceCreateDefaults | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reload = useCallback(async () => {
const [currentProject, loadedBalances] = await Promise.all([
getProject(projectId),
listEstimateBalances(projectId),
]);
setProject(currentProject);
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reload()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reload]);
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<TResult | null> {
if (!project) {
return null;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current ? { ...current, currentRevision: result.history.currentRevision } : current
);
await reload();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh revision and list so a stale state does not block the next
// attempt; the command error stays visible.
await reload().catch(() => undefined);
return null;
} finally {
setIsSaving(false);
}
}
async function handleCreate(input: CreateEstimateBalanceInput) {
const result = await runCommand(
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
"Stand konnte nicht angelegt werden."
);
if (result) {
setCreateDefaults(null);
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
}
}
async function handleDelete(balance: EstimateBalanceDto) {
if (
!window.confirm(
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
"Stand konnte nicht gelöscht werden."
);
}
const isBusy = isSaving || !project;
const voltage = project?.threePhaseVoltageV ?? 400;
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz</h1>
<p className="text-secondary mb-0">
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
</p>
</div>
</div>
<button
className="btn btn-primary"
disabled={isBusy}
onClick={() => setCreateDefaults({})}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={16} />
Neuer Stand
</button>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanzen werden geladen …
</p>
) : null}
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<h2 className="structure-column-title" id="estimate-list-heading">
Stände
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
</h2>
</div>
{balances.length ? (
<div className="table-responsive">
<table className="table table-hover align-middle mb-0 estimate-list-table">
<thead>
<tr>
<th>Stand</th>
<th>LPH</th>
<th>Datum</th>
<th className="text-end">AV kW</th>
<th className="text-end">AV kVA</th>
<th className="text-end">SV kW</th>
<th className="text-end">SV kVA</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => {
const result = calculateEstimateBalance(balance, voltage);
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
const rowCount = countRows(balance);
return (
<tr key={balance.id}>
<td>
<Link className="fw-semibold" href={href}>
{balance.name}
</Link>
<div className="text-secondary small">
{balance.groups.length} Kostengruppen ·{" "}
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
</div>
</td>
<td>{balance.workStage ?? "–"}</td>
<td>{formatIsoDateGerman(balance.dateIso)}</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kva)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kva)}
</td>
<td className="text-end">
<div className="d-inline-flex gap-1">
<Link
aria-label={`Stand „${balance.name}“ öffnen`}
className="btn btn-sm btn-outline-primary"
href={href}
title="Öffnen"
>
<FolderOpen aria-hidden="true" className="me-1" size={14} />
Öffnen
</Link>
<button
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isBusy}
onClick={() =>
setCreateDefaults({
name: createCopyName(balance.name),
workStage: balance.workStage,
template: "copy",
copyFromBalanceId: balance.id,
})
}
title="Als neuen Stand kopieren"
type="button"
>
<Copy aria-hidden="true" size={14} />
</button>
<button
aria-label={`Stand „${balance.name}“ löschen`}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isBusy}
onClick={() => void handleDelete(balance)}
title="Stand löschen"
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="text-secondary small px-3 py-2 mb-0">
Leistungen jeweils inklusive Reserve.
</p>
</div>
) : !isLoading ? (
<div className="card-body">
<p className="structure-empty mb-0">
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
Basis der Vorlage KG 400 anlegen.
</p>
</div>
) : null}
</section>
{createDefaults ? (
<EstimateBalanceCreateModal
balances={balances}
defaults={createDefaults}
isSaving={isSaving}
onClose={() => setCreateDefaults(null)}
onCreate={(input) => void handleCreate(input)}
/>
) : null}
</main>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,360 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import "../../../../frontend/print/print-document.css";
import {
deleteDocumentLogo,
documentLogoUrl,
getCircuitTree,
getDocumentBranding,
getProject,
listCircuitLists,
listDistributionBoards,
listFloors,
listProjectSnapshots,
updateOfficeProfile,
uploadDocumentLogo,
} from "../../../../frontend/utils/api";
import type {
DocumentLogoMetadataDto,
OfficeProfileDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../../../../frontend/types";
import { FormModal } from "../../../../frontend/components/form-modal";
import {
officeProfileFieldLabels,
officeProfileFieldMaxLengths,
} from "../../../../shared/constants/project-cover";
import { PrintDocument } from "../../../../frontend/print/print-document";
import {
assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy,
summarizePrintBoard,
type PrintBoardSummary,
} from "../../../../frontend/print/print-document-model";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../../../../shared/constants/document-branding";
type PlanFormat = "a4" | "a3";
interface PrintData {
project: ProjectDto;
summaries: PrintBoardSummary[];
namedSnapshots: ProjectSnapshotMetadataDto[];
}
const boardSortCollator = new Intl.Collator("de", { numeric: true });
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
function pageRules(project: ProjectDto, stand: string, format: PlanFormat) {
const marginBoxes = `
@bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; 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 `
@page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} }
@page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`;
}
export default function ProjectPrintPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [data, setData] = useState<PrintData | null>(null);
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
const [office, setOffice] = useState<OfficeProfileDto>({
officeName: null,
officeAddress: null,
});
const [officeDraft, setOfficeDraft] = useState<{
officeName: string;
officeAddress: string;
} | null>(null);
const [format, setFormat] = useState<PlanFormat>("a4");
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [printedAt] = useState(() => new Date());
useEffect(() => {
if (!projectId) {
return;
}
let cancelled = false;
async function load() {
const [project, boards, circuitLists, floors, snapshots, branding] =
await Promise.all([
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listFloors(projectId),
listProjectSnapshots(projectId),
getDocumentBranding(),
]);
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const summaries = await Promise.all(
[...boards]
.sort(
(left, right) =>
boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") ||
boardSortCollator.compare(left.name, right.name)
)
.flatMap((board) => {
const circuitList = circuitLists.find(
(list) => list.distributionBoardId === board.id
);
return circuitList ? [{ board, circuitList }] : [];
})
.map(async ({ board, circuitList }) =>
summarizePrintBoard({
board,
circuitListId: circuitList.id,
tree: await getCircuitTree(projectId, circuitList.id),
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
})
)
);
const finalProject = await getProject(projectId);
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
if (cancelled) {
return;
}
setData({
project,
summaries,
namedSnapshots: snapshots
.filter((snapshot) => snapshot.kind === "named")
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
});
setLogo(branding.logo);
setOffice(branding.office);
document.title = `Leistungsbilanz – ${project.name}`;
}
load().catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden.");
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const entries = useMemo(
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []),
[data]
);
const stand = useMemo(
() =>
new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(printedAt),
[printedAt]
);
const logoSrc = logo
? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}`
: null;
async function handleLogoUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) {
setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen.");
return;
}
if (file.size > maxDocumentLogoBytes) {
setError("Das Logo darf höchstens 2 MB groß sein.");
return;
}
setIsBusy(true);
setError(null);
try {
const result = await uploadDocumentLogo(file);
setLogo(result.logo);
setMessage("Logo hinterlegt. Es gilt für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleOfficeSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!officeDraft) {
return;
}
setIsBusy(true);
setError(null);
try {
const result = await updateOfficeProfile({
officeName: officeDraft.officeName.trim() || null,
officeAddress: officeDraft.officeAddress.trim() || null,
});
setOffice(result.office);
setOfficeDraft(null);
setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleLogoDelete() {
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
return;
}
setIsBusy(true);
setError(null);
try {
await deleteDocumentLogo();
setLogo(null);
setMessage("Logo entfernt.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden.");
} finally {
setIsBusy(false);
}
}
return (
// Paper output: always light, independent of the app's dark mode.
<div
className={format === "a3" ? "print-view pd-a3" : "print-view"}
data-bs-theme="light"
>
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
<div className="pd-toolbar">
<span className="pd-toolbar-title">
Seitenansicht{data ? ` · ${data.project.name}` : ""}
</span>
{message ? <span className="pd-toolbar-message">{message}</span> : null}
{error ? <span className="pd-toolbar-error">{error}</span> : null}
<label className="d-flex align-items-center gap-2">
Planformat
<select
className="form-select form-select-sm w-auto"
onChange={(event) => setFormat(event.target.value as PlanFormat)}
value={format}
>
<option value="a4">A4 quer</option>
<option value="a3">A3 quer</option>
</select>
</label>
<button
className="btn btn-sm btn-outline-primary"
disabled={isBusy}
onClick={() =>
setOfficeDraft({
officeName: office.officeName ?? "",
officeAddress: office.officeAddress ?? "",
})
}
type="button"
>
Büroangaben
</button>
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
<input
accept={documentLogoMimeTypes.join(",")}
disabled={isBusy}
hidden
onChange={(event) => void handleLogoUpload(event)}
type="file"
/>
</label>
{logo ? (
<button
className="btn btn-sm btn-outline-secondary"
disabled={isBusy}
onClick={() => void handleLogoDelete()}
type="button"
>
Logo entfernen
</button>
) : null}
<button
className="btn btn-sm btn-primary"
disabled={!data}
onClick={() => window.print()}
type="button"
>
Als PDF speichern / Drucken
</button>
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
Zum Projekt
</Link>
</div>
{officeDraft ? (
<FormModal
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
isSaving={isBusy}
onClose={() => setOfficeDraft(null)}
onSubmit={handleOfficeSave}
submitLabel="Büroangaben speichern"
title="Büroangaben"
>
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="office-name">
{officeProfileFieldLabels.officeName}
</label>
<input
autoFocus
className="form-control"
id="office-name"
maxLength={officeProfileFieldMaxLengths.officeName}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
}
value={officeDraft.officeName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="office-address">
{officeProfileFieldLabels.officeAddress}
</label>
<textarea
className="form-control"
id="office-address"
maxLength={officeProfileFieldMaxLengths.officeAddress}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
}
placeholder={"Straße Nr.\nPLZ Ort"}
rows={3}
value={officeDraft.officeAddress}
/>
</div>
</div>
</FormModal>
) : null}
{data ? (
<PrintDocument
entries={entries}
logoSrc={logoSrc}
namedSnapshots={data.namedSnapshots}
office={office}
printedAt={printedAt}
project={data.project}
/>
) : error ? null : (
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
)}
</div>
);
}

View file

@ -0,0 +1,792 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
import {
createBuilding,
createFloor,
createRoom,
deleteBuilding,
deleteFloor,
deleteRoom,
getProject,
listBuildings,
listFloors,
listRooms,
updateBuilding,
updateFloor,
updateRoom,
} from "../../../../frontend/utils/api";
import type {
BuildingDto,
CreateRoomInput,
FloorDto,
ProjectCommandResultDto,
ProjectDto,
RoomDto,
} from "../../../../frontend/types";
import {
ALL_SELECTION,
UNASSIGNED_BUILDING_LABEL,
UNASSIGNED_FLOOR_LABEL,
UNASSIGNED_SELECTION,
countFloorsByBuilding,
countRoomsByFloor,
defaultBuildingIdForNewFloor,
defaultFloorIdForNewRoom,
describeRoomLocation,
filterFloorsByBuilding,
filterRooms,
groupFloorsByBuilding,
hasFloorsWithoutBuilding,
normalizeBuildingSelection,
normalizeFloorSelection,
showsRoomsWithoutFloorEntry,
type StructureSelection,
} from "../../../../frontend/utils/building-structure";
import { FormModal } from "../../../../frontend/components/form-modal";
import { RoomModal } from "../../../../frontend/components/room-modal";
const BUILDING_HAS_FLOORS_MESSAGE =
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
type StructureDialog =
| { kind: "building"; building: BuildingDto | null }
| { kind: "floor"; floor: FloorDto | null }
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
function formatCount(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`;
}
function compareRooms(left: RoomDto, right: RoomDto) {
return (
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
left.roomName.localeCompare(right.roomName, "de")
);
}
interface StructureColumnProps {
addLabel: string;
children: ReactNode;
count: number;
headingId: string;
isAddDisabled: boolean;
onAdd: () => void;
title: string;
}
function StructureColumn({
addLabel,
children,
count,
headingId,
isAddDisabled,
onAdd,
title,
}: StructureColumnProps) {
return (
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<h2 className="structure-column-title" id={headingId}>
{title}
<span className="badge text-bg-secondary ms-2">{count}</span>
</h2>
<button
aria-label={addLabel}
className="btn btn-sm btn-outline-primary structure-icon-button"
disabled={isAddDisabled}
onClick={onAdd}
title={addLabel}
type="button"
>
<Plus aria-hidden="true" size={16} />
</button>
</div>
<ul className="list-group list-group-flush structure-list">{children}</ul>
</section>
);
}
interface StructureEntryProps {
actions?: ReactNode;
hint?: string;
isSelected?: boolean;
label: ReactNode;
meta?: string;
onSelect?: () => void;
}
function StructureEntry({
actions,
hint,
isSelected = false,
label,
meta,
onSelect,
}: StructureEntryProps) {
const content = (
<>
<span className="structure-entry-text">
<span className="structure-entry-label">{label}</span>
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
</span>
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
</>
);
return (
<li
className={
isSelected
? "list-group-item structure-entry is-selected"
: "list-group-item structure-entry"
}
>
{onSelect ? (
<button
aria-pressed={isSelected}
className="structure-entry-main"
onClick={onSelect}
type="button"
>
{content}
</button>
) : (
<div className="structure-entry-main">{content}</div>
)}
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
</li>
);
}
interface EntryActionsProps {
deleteLabel: string;
editLabel: string;
isDisabled: boolean;
onDelete: () => void;
onEdit: () => void;
}
function EntryActions({
deleteLabel,
editLabel,
isDisabled,
onDelete,
onEdit,
}: EntryActionsProps) {
return (
<>
<button
aria-label={editLabel}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isDisabled}
onClick={onEdit}
title={editLabel}
type="button"
>
<Pencil aria-hidden="true" size={14} />
</button>
<button
aria-label={deleteLabel}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isDisabled}
onClick={onDelete}
title={deleteLabel}
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</>
);
}
export default function BuildingStructurePage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [project, setProject] = useState<ProjectDto | null>(null);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
const [buildingSelection, setBuildingSelection] =
useState<StructureSelection>(ALL_SELECTION);
const [floorSelection, setFloorSelection] =
useState<StructureSelection>(ALL_SELECTION);
const [dialog, setDialog] = useState<StructureDialog | null>(null);
const [dialogName, setDialogName] = useState("");
const [dialogBuildingId, setDialogBuildingId] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reloadStructure = useCallback(async () => {
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
await Promise.all([
getProject(projectId),
listBuildings(projectId),
listFloors(projectId),
listRooms(projectId),
]);
setProject(currentProject);
setBuildings(loadedBuildings);
setFloors(loadedFloors);
setRooms(loadedRooms);
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reloadStructure()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Gebäudestruktur konnte nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reloadStructure]);
const effectiveBuildingSelection = normalizeBuildingSelection(
buildingSelection,
floors,
buildings
);
const effectiveFloorSelection = normalizeFloorSelection(
floorSelection,
rooms,
floors,
buildings,
effectiveBuildingSelection
);
const visibleFloors = useMemo(
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
[buildings, effectiveBuildingSelection, floors]
);
const visibleFloorGroups = useMemo(
() =>
effectiveBuildingSelection === ALL_SELECTION && buildings.length
? groupFloorsByBuilding(visibleFloors, buildings)
: [{ buildingId: null, label: "", floors: visibleFloors }],
[buildings, effectiveBuildingSelection, visibleFloors]
);
const visibleRooms = useMemo(
() =>
filterRooms(
rooms,
floors,
buildings,
effectiveBuildingSelection,
effectiveFloorSelection
).sort(compareRooms),
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
);
const roomsInBuildingSelection = useMemo(
() =>
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
.length,
[buildings, effectiveBuildingSelection, floors, rooms]
);
const floorCountByBuilding = useMemo(
() => countFloorsByBuilding(floors, buildings),
[buildings, floors]
);
const roomCountByFloor = useMemo(
() => countRoomsByFloor(rooms, floors),
[floors, rooms]
);
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
rooms,
floors,
effectiveBuildingSelection
);
const isBusy = isSaving || !project;
function selectBuilding(selection: StructureSelection) {
setBuildingSelection(selection);
setFloorSelection(ALL_SELECTION);
}
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<boolean> {
if (!project) {
return false;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current
? { ...current, currentRevision: result.history.currentRevision }
: current
);
await reloadStructure();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh the revision and lists so a stale state does not block the
// next attempt; the command error stays visible.
await reloadStructure().catch(() => undefined);
return false;
} finally {
setIsSaving(false);
}
}
function openBuildingDialog(building: BuildingDto | null) {
setDialogName(building?.name ?? "");
setDialog({ kind: "building", building });
}
function openFloorDialog(floor: FloorDto | null) {
setDialogName(floor?.name ?? "");
setDialogBuildingId(
floor
? floor.buildingId ?? ""
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
);
setDialog({ kind: "floor", floor });
}
function openRoomDialog(room: RoomDto | null) {
setDialog({
kind: "room",
room,
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
});
}
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (dialog?.kind !== "building" || !dialogName.trim()) {
return;
}
const editingBuilding = dialog.building;
const name = dialogName.trim();
// An unchanged dialog is not a command; the backend rejects no-op updates.
if (editingBuilding && editingBuilding.name === name) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingBuilding
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
: createBuilding(projectId, { name }, expectedRevision),
"Gebäude konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (dialog?.kind !== "floor" || !dialogName.trim()) {
return;
}
const editingFloor = dialog.floor;
const input = {
name: dialogName.trim(),
buildingId: dialogBuildingId || null,
};
if (
editingFloor &&
editingFloor.name === input.name &&
editingFloor.buildingId === input.buildingId
) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingFloor
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
: createFloor(projectId, input, expectedRevision),
"Etage konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleSaveRoom(input: CreateRoomInput) {
if (dialog?.kind !== "room") {
return;
}
const editingRoom = dialog.room;
if (
editingRoom &&
editingRoom.roomNumber === input.roomNumber.trim() &&
editingRoom.roomName === input.roomName.trim() &&
editingRoom.floorId === (input.floorId || null)
) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingRoom
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
: createRoom(projectId, input, expectedRevision),
"Raum konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleDeleteBuilding(building: BuildingDto) {
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
setError(BUILDING_HAS_FLOORS_MESSAGE);
return;
}
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
return;
}
await runCommand(
(expectedRevision) =>
deleteBuilding(projectId, building.id, expectedRevision),
"Gebäude konnte nicht gelöscht werden."
);
}
async function handleDeleteFloor(floor: FloorDto) {
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
return;
}
await runCommand(
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
"Etage konnte nicht gelöscht werden."
);
}
async function handleDeleteRoom(room: RoomDto) {
if (
!window.confirm(
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
"Raum konnte nicht gelöscht werden."
);
}
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Gebäudestruktur</h1>
<p className="text-secondary mb-0">
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
</p>
</div>
</div>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Gebäudestruktur wird geladen …
</p>
) : null}
<div className="structure-columns">
<StructureColumn
addLabel="Gebäude hinzufügen"
count={buildings.length}
headingId="structure-buildings-heading"
isAddDisabled={isBusy}
onAdd={() => openBuildingDialog(null)}
title="Gebäude"
>
<StructureEntry
isSelected={effectiveBuildingSelection === ALL_SELECTION}
label="Alle"
meta={formatCount(floors.length, "Etage", "Etagen")}
onSelect={() => selectBuilding(ALL_SELECTION)}
/>
{buildings.map((building) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Gebäude „${building.name}“ löschen`}
editLabel={`Gebäude „${building.name}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteBuilding(building)}
onEdit={() => openBuildingDialog(building)}
/>
}
isSelected={effectiveBuildingSelection === building.id}
key={building.id}
label={building.name}
meta={formatCount(
floorCountByBuilding.get(building.id) ?? 0,
"Etage",
"Etagen"
)}
onSelect={() => selectBuilding(building.id)}
/>
))}
{showUnassignedBuilding ? (
<StructureEntry
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
meta={formatCount(
floorCountByBuilding.get(null) ?? 0,
"Etage",
"Etagen"
)}
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
/>
) : null}
{!buildings.length && !isLoading ? (
<li className="list-group-item structure-empty">
Noch keine Gebäude vorhanden.
</li>
) : null}
</StructureColumn>
<StructureColumn
addLabel="Etage hinzufügen"
count={visibleFloors.length}
headingId="structure-floors-heading"
isAddDisabled={isBusy}
onAdd={() => openFloorDialog(null)}
title="Etagen"
>
<StructureEntry
isSelected={effectiveFloorSelection === ALL_SELECTION}
label="Alle"
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
onSelect={() => setFloorSelection(ALL_SELECTION)}
/>
{visibleFloorGroups.map((group) => (
<FloorGroupEntries
key={group.buildingId ?? "unassigned"}
label={group.label}
>
{group.floors.map((floor) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Etage „${floor.name}“ löschen`}
editLabel={`Etage „${floor.name}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteFloor(floor)}
onEdit={() => openFloorDialog(floor)}
/>
}
isSelected={effectiveFloorSelection === floor.id}
key={floor.id}
label={floor.name}
meta={formatCount(
roomCountByFloor.get(floor.id) ?? 0,
"Raum",
"Räume"
)}
onSelect={() => setFloorSelection(floor.id)}
/>
))}
</FloorGroupEntries>
))}
{showUnassignedFloor ? (
<StructureEntry
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
/>
) : null}
{!visibleFloors.length && !isLoading ? (
<li className="list-group-item structure-empty">
{floors.length
? "Keine Etagen in dieser Auswahl."
: "Noch keine Etagen vorhanden."}
</li>
) : null}
</StructureColumn>
<StructureColumn
addLabel="Raum hinzufügen"
count={visibleRooms.length}
headingId="structure-rooms-heading"
isAddDisabled={isBusy}
onAdd={() => openRoomDialog(null)}
title="Räume"
>
{visibleRooms.map((room) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteRoom(room)}
onEdit={() => openRoomDialog(room)}
/>
}
hint={
effectiveFloorSelection === ALL_SELECTION
? describeRoomLocation(room, floors, buildings)
: undefined
}
key={room.id}
label={
<>
<span className="structure-room-number">{room.roomNumber}</span>{" "}
{room.roomName}
</>
}
/>
))}
{!visibleRooms.length && !isLoading ? (
<li className="list-group-item structure-empty">
{rooms.length
? "Keine Räume in dieser Auswahl."
: "Noch keine Räume vorhanden."}
</li>
) : null}
</StructureColumn>
</div>
{dialog?.kind === "building" ? (
<FormModal
isSaving={isSaving}
onClose={() => setDialog(null)}
onSubmit={handleSaveBuilding}
submitDisabled={!dialogName.trim()}
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
>
<label className="form-label" htmlFor="building-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="building-name"
maxLength={200}
onChange={(event) => setDialogName(event.target.value)}
placeholder="z. B. Haus A"
required
value={dialogName}
/>
</FormModal>
) : null}
{dialog?.kind === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => setDialog(null)}
onSubmit={handleSaveFloor}
submitDisabled={!dialogName.trim()}
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="floor-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="floor-name"
maxLength={200}
onChange={(event) => setDialogName(event.target.value)}
placeholder="z. B. EG"
required
value={dialogName}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="floor-building">
Gebäude
</label>
<select
className="form-select"
id="floor-building"
onChange={(event) => setDialogBuildingId(event.target.value)}
value={dialogBuildingId}
>
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
{buildings.map((building) => (
<option key={building.id} value={building.id}>
{building.name}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{dialog?.kind === "room" ? (
<RoomModal
buildings={buildings}
floors={floors}
initialRoom={dialog.room ?? undefined}
initialValues={
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
}
isSaving={isSaving}
onClose={() => setDialog(null)}
onSave={handleSaveRoom}
/>
) : null}
</main>
);
}
function FloorGroupEntries({
children,
label,
}: {
children: ReactNode;
label: string;
}) {
return (
<>
{label ? (
<li className="list-group-item structure-group-label">
{label}
</li>
) : null}
{children}
</>
);
}

View file

@ -0,0 +1,12 @@
CREATE TABLE `document_branding_assets` (
`key` text PRIMARY KEY NOT NULL,
`mime_type` text NOT NULL,
`file_name` text,
`data` blob NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `remark` text;

View file

@ -0,0 +1,10 @@
CREATE TABLE `document_branding_settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_editor` text;

View file

@ -0,0 +1,9 @@
CREATE TABLE `buildings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;

View file

@ -0,0 +1,42 @@
CREATE TABLE `estimate_balances` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`work_stage` text,
`date_iso` text,
`reserve_av_percent` real DEFAULT 0 NOT NULL,
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
CREATE TABLE `estimate_groups` (
`id` text PRIMARY KEY NOT NULL,
`balance_id` text NOT NULL,
`cost_group` text DEFAULT '' NOT NULL,
`title` text NOT NULL,
`editor` text,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
CREATE TABLE `estimate_rows` (
`id` text PRIMARY KEY NOT NULL,
`group_id` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`supply` text,
`name` text DEFAULT '' NOT NULL,
`quantity` real DEFAULT 0 NOT NULL,
`unit` text DEFAULT 'piece' NOT NULL,
`connection` text,
`power_per_unit_w` real DEFAULT 0 NOT NULL,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
`cos_phi` real DEFAULT 1 NOT NULL,
`safety_supply` integer DEFAULT false NOT NULL,
`remark` text,
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,34 @@
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1790411540872,
"tag": "0007_board_info_and_document_logo",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1790411560953,
"tag": "0008_project_cover_and_office_profile",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790419185882,
"tag": "0009_buildings",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790438092269,
"tag": "0010_estimate_balances",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,27 @@
import { and, asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { buildings } from "../schema/buildings.js";
export class BuildingRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
return this.database
.select()
.from(buildings)
.where(eq(buildings.projectId, projectId))
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
}
async findById(projectId: string, buildingId: string) {
return (
this.database
.select()
.from(buildings)
.where(
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
)
.get() ?? null
);
}
}

View file

@ -388,10 +388,15 @@ function structureMatches(
) {
const {
simultaneityFactor,
boardNumber,
location,
fedFrom,
remark,
...actualDistributionBoard
} = actual.board;
if (
simultaneityFactor !== 1 ||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard

View file

@ -10,6 +10,7 @@ import {
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import {
assertDistributionBoardSubtreeSnapshot,
normalizeDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import type {
@ -63,19 +64,20 @@ export class DistributionBoardSubtreeProjectCommandRepository
projectId: string,
command: DistributionBoardSubtreeProjectCommand
) {
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
command.payload.snapshot
);
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(
command.payload.snapshot
);
this.insert(database, projectId, snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
}
if (command.type === distributionBoardDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
command.payload.snapshot.distributionBoard.id
snapshot.distributionBoard.id
);
if (!sameSnapshot(current, command.payload.snapshot)) {
if (!sameSnapshot(current, snapshot)) {
throw new Error(
"Distribution-board subtree changed before deletion."
);
@ -97,9 +99,7 @@ export class DistributionBoardSubtreeProjectCommandRepository
"Distribution-board subtree could not be deleted."
);
}
return createDistributionBoardInsertSubtreeProjectCommand(
command.payload.snapshot
);
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
}
throw new Error("Unsupported distribution-board subtree command.");
}

View file

@ -0,0 +1,86 @@
import { eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
documentBrandingAssets,
type DocumentBrandingAsset,
} from "../schema/document-branding-assets.js";
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
// Plain repository without revision semantics: branding is global office
// configuration, not a project mutation.
export class DocumentBrandingRepository {
constructor(private readonly db: AppDatabase) {}
get(key: string): DocumentBrandingAsset | undefined {
return this.db
.select()
.from(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.get();
}
upsert(asset: {
key: string;
mimeType: string;
fileName: string | null;
data: Buffer;
}): DocumentBrandingAsset {
const updatedAt = new Date();
return this.db
.insert(documentBrandingAssets)
.values({ ...asset, updatedAt })
.onConflictDoUpdate({
target: documentBrandingAssets.key,
set: {
mimeType: asset.mimeType,
fileName: asset.fileName,
data: asset.data,
updatedAt,
},
})
.returning()
.get();
}
delete(key: string): boolean {
return (
this.db
.delete(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.run().changes > 0
);
}
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
const rows = this.db
.select()
.from(documentBrandingSettings)
.where(inArray(documentBrandingSettings.key, [...keys]))
.all();
return Object.fromEntries(
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
) as Record<TKey, string | null>;
}
/** Stores the given values atomically; `null` removes a setting. */
setSettings(values: Record<string, string | null>) {
const updatedAt = new Date();
this.db.transaction((tx) => {
for (const [key, value] of Object.entries(values)) {
if (value === null) {
tx.delete(documentBrandingSettings)
.where(eq(documentBrandingSettings.key, key))
.run();
} else {
tx.insert(documentBrandingSettings)
.values({ key, value, updatedAt })
.onConflictDoUpdate({
target: documentBrandingSettings.key,
set: { value, updatedAt },
})
.run();
}
}
});
}
}

View file

@ -0,0 +1,132 @@
import { eq } from "drizzle-orm";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
normalizeEstimateBalanceSnapshot,
sameEstimateBalance,
type EstimateBalanceProjectCommand,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type {
EstimateBalanceProjectCommandStore,
ExecuteEstimateBalanceCommandInput,
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
export class EstimateBalanceProjectCommandRepository
implements EstimateBalanceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteEstimateBalanceCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: EstimateBalanceProjectCommand
): EstimateBalanceProjectCommand {
switch (command.type) {
case estimateBalanceInsertCommandType:
assertEstimateBalanceInsertProjectCommand(command);
return this.insert(database, projectId, command.payload.balance);
case estimateBalanceDeleteCommandType:
assertEstimateBalanceDeleteProjectCommand(command);
return this.delete(database, projectId, command.payload.balance);
case estimateBalanceUpdateCommandType:
assertEstimateBalanceUpdateProjectCommand(command);
return this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
}
}
private insert(
database: AppDatabase,
projectId: string,
balance: EstimateBalanceSnapshot
) {
if (balance.projectId !== projectId) {
throw new Error("Estimate-balance snapshot does not belong to project.");
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: estimateBalances.id })
.from(estimateBalances)
.where(eq(estimateBalances.id, balance.id))
.get();
if (existing) {
throw new Error("Estimate-balance id already exists.");
}
const normalized = normalizeEstimateBalanceSnapshot(balance);
insertEstimateBalance(database, normalized);
return createEstimateBalanceDeleteProjectCommand(normalized);
}
private delete(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "deletion");
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
throw new Error("Estimate balance could not be deleted.");
}
return createEstimateBalanceInsertProjectCommand(expected);
}
private update(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "update");
// Group and row ids stay stable; replacing the whole document keeps the
// command exact and its inverse trivially the swapped pair.
deleteEstimateBalance(database, projectId, expected.id);
const normalized = normalizeEstimateBalanceSnapshot(target);
insertEstimateBalance(database, normalized);
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
}
private assertPersisted(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
action: string
) {
const [persisted] = readEstimateBalances(database, projectId, expected.id);
if (!persisted || !sameEstimateBalance(persisted, expected)) {
throw new Error(`Estimate balance changed before ${action}.`);
}
}
}

View file

@ -0,0 +1,121 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import {
estimateBalances,
estimateGroups,
estimateRows,
} from "../schema/estimate-balances.js";
/** Reads complete balance documents of a project, ordered like the UI shows them. */
export function readEstimateBalances(
database: AppDatabase,
projectId: string,
balanceId?: string
): EstimateBalanceSnapshot[] {
const balances = database
.select()
.from(estimateBalances)
.where(
balanceId
? and(
eq(estimateBalances.projectId, projectId),
eq(estimateBalances.id, balanceId)
)
: eq(estimateBalances.projectId, projectId)
)
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
.all();
if (!balances.length) {
return [];
}
const groups = database
.select()
.from(estimateGroups)
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
.all();
const rows = groups.length
? database
.select()
.from(estimateRows)
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
.all()
: [];
return balances.map((balance) => ({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
groups: groups
.filter((group) => group.balanceId === balance.id)
.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: rows
.filter((row) => row.groupId === group.id)
.map(({ groupId: _groupId, ...row }) => row),
})),
}));
}
/** Writes a complete balance document (the caller removed an older version first). */
export function insertEstimateBalance(
database: AppDatabase,
balance: EstimateBalanceSnapshot
) {
database
.insert(estimateBalances)
.values({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
})
.run();
for (const group of balance.groups) {
database
.insert(estimateGroups)
.values({
id: group.id,
balanceId: balance.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
})
.run();
if (group.rows.length) {
database
.insert(estimateRows)
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
.run();
}
}
}
/** Removes a balance with its groups and rows (foreign keys cascade). */
export function deleteEstimateBalance(
database: AppDatabase,
projectId: string,
balanceId: string
) {
return database
.delete(estimateBalances)
.where(
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
)
.run().changes;
}

View file

@ -0,0 +1,60 @@
import { asc, eq } from "drizzle-orm";
import type {
DetailedDeviceRowPower,
EstimateBalanceSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { readEstimateBalances } from "./estimate-balance.persistence.js";
/** Read side of the estimate-balance module; writes go through commands. */
export class EstimateBalanceRepository {
constructor(private readonly database: AppDatabase) {}
listByProject(projectId: string): EstimateBalanceSnapshot[] {
return readEstimateBalances(this.database, projectId);
}
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
}
nextSortOrder(projectId: string): number {
const rows = this.database
.select({ sortOrder: estimateBalances.sortOrder })
.from(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.orderBy(asc(estimateBalances.sortOrder))
.all();
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
}
/**
* Device rows of all circuit lists of the project with their total power
* (quantity · power per unit · device GZF, like the circuit tree).
*/
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
return this.database
.select({
costGroup: circuitDeviceRows.costGroup,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all()
.map((row) => ({
costGroup: row.costGroup,
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
cosPhi: row.cosPhi,
}));
}
}

View file

@ -1,23 +1,34 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectBuildingDeleteProjectCommand,
assertProjectBuildingInsertProjectCommand,
assertProjectBuildingUpdateProjectCommand,
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand,
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingUpdateProjectCommand,
createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand,
createProjectFloorUpdateProjectCommand,
createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand,
createProjectRoomUpdateProjectCommand,
projectBuildingDeleteCommandType,
projectBuildingInsertCommandType,
projectBuildingUpdateCommandType,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectFloorUpdateCommandType,
projectRoomDeleteCommandType,
projectRoomInsertCommandType,
projectRoomUpdateCommandType,
upgradeProjectFloorCommand,
type ProjectBuildingSnapshot,
type ProjectFloorSnapshot,
type ProjectLocationStructureProjectCommand,
type ProjectRoomSnapshot,
@ -27,6 +38,7 @@ import type {
ProjectLocationStructureProjectCommandStore,
} from "../../domain/ports/project-location-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { buildings } from "../schema/buildings.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { floors } from "../schema/floors.js";
import { distributionBoards } from "../schema/distribution-boards.js";
@ -40,11 +52,14 @@ export class ProjectLocationStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectLocationStructureCommandInput) {
// Forward and inverse are persisted with one schema version, so a
// version 1 floor command from older history is upgraded first.
const command = toCurrentCommandVersion(input.command);
return executeProjectCommandTransaction(
this.database,
input,
{ ...input, command },
(tx) =>
this.applyCommand(tx, input.projectId, input.command)
this.applyCommand(tx, input.projectId, command)
);
}
@ -54,23 +69,43 @@ export class ProjectLocationStructureProjectCommandRepository
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
case projectFloorInsertCommandType:
case projectBuildingInsertCommandType:
assertProjectBuildingInsertProjectCommand(command);
return this.insertBuilding(database, projectId, command.payload.building);
case projectBuildingDeleteCommandType:
assertProjectBuildingDeleteProjectCommand(command);
return this.deleteBuilding(database, projectId, command.payload.building);
case projectBuildingUpdateCommandType:
assertProjectBuildingUpdateProjectCommand(command);
return this.updateBuilding(
database,
projectId,
command.payload.expected,
command.payload.target
);
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.insertFloor(
database,
projectId,
command.payload.floor
current.payload.floor
);
case projectFloorDeleteCommandType:
}
case projectFloorDeleteCommandType: {
assertProjectFloorDeleteProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.deleteFloor(
database,
projectId,
command.payload.floor
current.payload.floor
);
case projectFloorUpdateCommandType:
}
case projectFloorUpdateCommandType: {
assertProjectFloorUpdateProjectCommand(command);
return this.updateFloor(database, projectId, command.payload.expected, command.payload.target);
const current = upgradeProjectFloorCommand(command);
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
}
case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command);
return this.insertRoom(
@ -100,6 +135,7 @@ export class ProjectLocationStructureProjectCommandRepository
if (floor.projectId !== projectId) {
throw new Error("Project-floor snapshot does not belong to project.");
}
this.assertBuildingInProject(database, projectId, floor.buildingId);
const existing = database
.select({ id: floors.id })
.from(floors)
@ -167,6 +203,104 @@ export class ProjectLocationStructureProjectCommandRepository
return createProjectFloorInsertProjectCommand(expected);
}
private insertBuilding(
database: AppDatabase,
projectId: string,
building: ProjectBuildingSnapshot
) {
this.assertProjectExists(database, projectId);
if (building.projectId !== projectId) {
throw new Error("Project-building snapshot does not belong to project.");
}
const existing = database
.select({ id: buildings.id })
.from(buildings)
.where(eq(buildings.id, building.id))
.get();
if (existing) {
throw new Error("Project-building id already exists.");
}
database.insert(buildings).values(building).run();
return createProjectBuildingDeleteProjectCommand(building);
}
private deleteBuilding(
database: AppDatabase,
projectId: string,
expected: ProjectBuildingSnapshot
) {
const persisted = database
.select()
.from(buildings)
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project building changed before deletion.");
}
const referencedFloor = database
.select({ id: floors.id })
.from(floors)
.where(eq(floors.buildingId, expected.id))
.limit(1)
.get();
if (referencedFloor) {
throw new Error(
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind."
);
}
const deleted = database
.delete(buildings)
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.run();
if (deleted.changes !== 1) {
throw new Error("Project building could not be deleted.");
}
return createProjectBuildingInsertProjectCommand(expected);
}
private updateBuilding(
database: AppDatabase,
projectId: string,
expected: ProjectBuildingSnapshot,
target: ProjectBuildingSnapshot
) {
const persisted = database
.select()
.from(buildings)
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project building changed before update.");
}
const updated = database
.update(buildings)
.set({ name: target.name })
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.run();
if (updated.changes !== 1) {
throw new Error("Das Gebäude konnte nicht aktualisiert werden.");
}
return createProjectBuildingUpdateProjectCommand(target, expected);
}
private assertBuildingInProject(
database: AppDatabase,
projectId: string,
buildingId: string | null
) {
if (buildingId === null) {
return;
}
const building = database
.select({ id: buildings.id })
.from(buildings)
.where(and(eq(buildings.id, buildingId), eq(buildings.projectId, projectId)))
.get();
if (!building) {
throw new Error("Project floor references a foreign building.");
}
}
private insertRoom(
database: AppDatabase,
projectId: string,
@ -217,9 +351,10 @@ export class ProjectLocationStructureProjectCommandRepository
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before update.");
}
this.assertBuildingInProject(database, projectId, target.buildingId);
const updated = database
.update(floors)
.set({ name: target.name })
.set({ name: target.name, buildingId: target.buildingId })
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
.run();
if (updated.changes !== 1) {
@ -326,6 +461,24 @@ export class ProjectLocationStructureProjectCommandRepository
}
}
function toCurrentCommandVersion(
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
case projectFloorInsertCommandType:
assertProjectFloorInsertProjectCommand(command);
return upgradeProjectFloorCommand(command);
case projectFloorDeleteCommandType:
assertProjectFloorDeleteProjectCommand(command);
return upgradeProjectFloorCommand(command);
case projectFloorUpdateCommandType:
assertProjectFloorUpdateProjectCommand(command);
return upgradeProjectFloorCommand(command);
default:
return command;
}
}
function sameRecord(
expected: object,
actual: object

View file

@ -1,9 +1,12 @@
import { and, eq, isNotNull } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js";
import { projectCoverFields } from "../../shared/constants/project-cover.js";
import type {
ExecuteProjectSettingsUpdateCommandInput,
ProjectSettingsProjectCommandStore,
@ -40,13 +43,27 @@ export class ProjectSettingsProjectCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentCoverValues = {
siteAddress: current.siteAddress,
workStage: current.workStage,
projectLead: current.projectLead,
projectEditor: current.projectEditor,
};
// Older command versions do not carry every value; missing values keep
// the current project state and are therefore also absent from the
// inverse of the same version.
const command = input.command;
const target: ProjectSettingsValues =
input.command.schemaVersion === 1
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? {
...input.command.payload,
...command.payload,
isPublicBuilding: current.isPublicBuilding,
...currentCoverValues,
}
: input.command.payload;
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues }
: command.payload;
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
@ -62,11 +79,15 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
...currentCoverValues,
};
const inverse =
input.command.schemaVersion === 1
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? createBaselineProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx
.update(projects)
@ -82,11 +103,32 @@ export class ProjectSettingsProjectCommandRepository
}
}
function createWithoutCoverProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload: {
name: values.name,
internalProjectNumber: values.internalProjectNumber,
externalProjectNumber: values.externalProjectNumber,
buildingOwner: values.buildingOwner,
description: values.description,
isPublicBuilding: values.isPublicBuilding,
singlePhaseVoltageV: values.singlePhaseVoltageV,
threePhaseVoltageV: values.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
values.enabledDistributionBoardSupplyTypes,
},
};
}
function createBaselineProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: 1 as const,
schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload: {
name: values.name,
@ -114,8 +156,9 @@ function projectSettingsEqual(
current.description === target.description &&
current.isPublicBuilding === target.isPublicBuilding &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
&& sameSupplyTypes(
current.threePhaseVoltageV === target.threePhaseVoltageV &&
projectCoverFields.every((field) => current[field] === target[field]) &&
sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes
)

View file

@ -5,6 +5,7 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
deserializeProjectStateSnapshot,
isSupportedProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@ -138,7 +139,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
if (!isSupportedProjectStateSnapshotSchemaVersion(stored.schemaVersion)) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);

View file

@ -3,6 +3,8 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
import {
assertProjectStateRestoreCommand,
createProjectStateRestoreCommand,
createProjectTransferRestoreCommand,
projectStateRestoreCommandSchemaVersion,
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
@ -13,6 +15,8 @@ import type {
ProjectStateRestoreCommandStore,
} from "../../domain/ports/project-state-restore-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
@ -21,6 +25,7 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
@ -31,6 +36,11 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
readProjectStateSnapshot,
@ -65,10 +75,39 @@ export class ProjectStateRestoreCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
const transferCommand =
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
? input.command
: null;
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(tx, input, current.state)
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
if (
transferCommand &&
canonicalEstimateBalances(currentEstimateBalances) !==
canonicalEstimateBalances(
transferCommand.payload.expectedEstimateBalances
) &&
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
@ -81,14 +120,28 @@ export class ProjectStateRestoreCommandRepository
input.command.payload.targetState
);
replaceProjectState(tx, targetState);
if (transferCommand) {
replaceEstimateBalances(
tx,
input.projectId,
transferCommand.payload.targetEstimateBalances
);
}
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
const inverse = transferCommand
? createProjectTransferRestoreCommand(
persistedTarget.payloadSha256,
current.state,
transferCommand.payload.targetEstimateBalances,
currentEstimateBalances
)
: createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
return inverse;
}
@ -97,7 +150,8 @@ export class ProjectStateRestoreCommandRepository
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot
currentState: ProjectStateSnapshot,
currentEstimateBalances: EstimateBalanceSnapshot[]
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
@ -121,25 +175,45 @@ function matchesHistoryRestoreTarget(
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
const command = JSON.parse(serialized) as unknown;
assertProjectStateRestoreCommand(command);
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
const stateMatches =
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState);
if (!stateMatches) {
return false;
}
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
canonicalEstimateBalances(currentEstimateBalances);
} catch {
return false;
}
}
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
return JSON.stringify(
[...balances]
.sort((left, right) => left.id.localeCompare(right.id))
.map((balance) => canonicalEstimateBalance(balance))
);
}
function replaceEstimateBalances(
database: AppDatabase,
projectId: string,
balances: EstimateBalanceSnapshot[]
) {
database
.delete(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.run();
for (const balance of balances) {
insertEstimateBalance(database, balance);
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,
@ -164,6 +238,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
}))
),
projectDevices: byId(state.projectDevices),
buildings: byId(state.buildings),
floors: byId(state.floors),
rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration,
@ -219,6 +294,10 @@ function replaceProjectState(
.delete(floors)
.where(eq(floors.projectId, state.project.id))
.run();
database
.delete(buildings)
.where(eq(buildings.projectId, state.project.id))
.run();
database
.update(projects)
@ -233,10 +312,15 @@ function replaceProjectState(
threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes,
siteAddress: state.project.siteAddress,
workStage: state.project.workStage,
projectLead: state.project.projectLead,
projectEditor: state.project.projectEditor,
})
.where(eq(projects.id, state.project.id))
.run();
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors);
if (state.externalCsvConfiguration !== null) {
database

View file

@ -15,6 +15,7 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
@ -46,6 +47,10 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision,
})
.from(projects)
@ -215,6 +220,12 @@ export function readProjectStateSnapshot(
.where(eq(projectDevices.projectId, projectId))
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
.all();
const buildingRows = database
.select()
.from(buildings)
.where(eq(buildings.projectId, projectId))
.orderBy(asc(buildings.sortOrder), asc(buildings.id))
.all();
const floorRows = database
.select()
.from(floors)
@ -249,6 +260,10 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes,
siteAddress: project.siteAddress,
workStage: project.workStage,
projectLead: project.projectLead,
projectEditor: project.projectEditor,
},
distributionBoards: boardRows,
externalCsvConfiguration,
@ -265,6 +280,7 @@ export function readProjectStateSnapshot(
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
})),
projectDevices: projectDeviceRows,
buildings: buildingRows,
floors: floorRows,
rooms: roomRows,
});

View file

@ -3,9 +3,11 @@ import { eq } from "drizzle-orm";
import {
createProjectTransferEnvelope,
parseProjectTransferEnvelope,
remapEstimateBalances,
remapProjectState,
serializeProjectTransferPayload,
} from "../../domain/models/project-transfer.model.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
@ -16,6 +18,7 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
@ -25,6 +28,10 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
@ -39,10 +46,15 @@ export class ProjectTransferRepository implements ProjectTransferStore {
if (!snapshot) {
return null;
}
const estimateBalances = readEstimateBalances(this.database, projectId);
const payloadSha256 = hashProjectStatePayload(
serializeProjectTransferPayload(snapshot.state, estimateBalances)
);
return createProjectTransferEnvelope(
new Date().toISOString(),
snapshot.payloadSha256,
snapshot.state
payloadSha256,
snapshot.state,
estimateBalances
);
}
@ -60,10 +72,21 @@ export class ProjectTransferRepository implements ProjectTransferStore {
projectId,
() => crypto.randomUUID()
);
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
const targetEstimateBalances =
transfer.projectState.project.id === projectId
? transfer.estimateBalances
: remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
return {
command: createProjectStateRestoreCommand(
command: createProjectTransferRestoreCommand(
current.payloadSha256,
targetState
targetState,
currentEstimateBalances,
targetEstimateBalances
),
};
}
@ -77,20 +100,39 @@ export class ProjectTransferRepository implements ProjectTransferStore {
() => crypto.randomUUID(),
`${transfer.projectState.project.name} (Kopie)`
);
this.database.transaction((tx) => insertProjectState(tx, state));
const estimateBalances = remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
this.database.transaction((tx) => {
insertProjectState(tx, state);
for (const balance of estimateBalances) {
insertEstimateBalance(tx, balance);
}
});
return { projectId, name: state.project.name };
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
? hashProjectStatePayload(
isLegacy
? JSON.stringify(value.projectState)
: JSON.stringify({
projectState: value.projectState,
estimateBalances: value.estimateBalances,
})
)
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
const payloadJson = isLegacy
? serializeProjectStateSnapshot(transfer.projectState)
: serializeProjectTransferPayload(
transfer.projectState,
transfer.estimateBalances
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
rawPayloadSha256 !== transfer.payloadSha256
@ -127,6 +169,7 @@ function insertProjectState(
.values(state.externalCsvConfiguration)
.run();
}
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors);
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);

View file

@ -31,6 +31,10 @@ export class ProjectRepository {
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 0,
};
await db.insert(projects).values(project);

View file

@ -0,0 +1,11 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const buildings = sqliteTable("buildings", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
});

View file

@ -16,5 +16,9 @@ export const distributionBoards = sqliteTable("distribution_boards", {
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
boardNumber: text("board_number"),
location: text("location"),
fedFrom: text("fed_from"),
remark: text("remark"),
});

View file

@ -0,0 +1,18 @@
import { sql } from "drizzle-orm";
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Office-wide branding for printed documents (currently only the logo shown
// on the cover page and plan title blocks). Global application data, not part
// of any project: it is deliberately outside project revisions, logical
// project snapshots and project transfer.
export const documentBrandingAssets = sqliteTable("document_branding_assets", {
key: text("key").primaryKey(),
mimeType: text("mime_type").notNull(),
fileName: text("file_name"),
data: blob("data", { mode: "buffer" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export type DocumentBrandingAsset = typeof documentBrandingAssets.$inferSelect;

View file

@ -0,0 +1,13 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Office-wide text settings for printed documents (e.g. office name and
// address on the cover page). Global application data like
// document_branding_assets: outside project revisions, snapshots and transfer.
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});

View file

@ -0,0 +1,60 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
// circuit structure. See docs/estimate-balance.md.
export const estimateBalances = sqliteTable(
"estimate_balances",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workStage: text("work_stage"),
dateIso: text("date_iso"),
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
);
export const estimateGroups = sqliteTable(
"estimate_groups",
{
id: text("id").primaryKey(),
balanceId: text("balance_id")
.notNull()
.references(() => estimateBalances.id, { onDelete: "cascade" }),
costGroup: text("cost_group").notNull().default(""),
title: text("title").notNull(),
editor: text("editor"),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
);
export const estimateRows = sqliteTable(
"estimate_rows",
{
id: text("id").primaryKey(),
groupId: text("group_id")
.notNull()
.references(() => estimateGroups.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
supply: text("supply"),
name: text("name").notNull().default(""),
quantity: real("quantity").notNull().default(0),
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
connection: text("connection", { enum: ["fixed", "socket"] }),
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
cosPhi: real("cos_phi").notNull().default(1),
safetySupply: integer("safety_supply", { mode: "boolean" })
.notNull()
.default(false),
remark: text("remark"),
},
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
);

View file

@ -1,4 +1,5 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { buildings } from "./buildings.js";
import { projects } from "./projects.js";
export const floors = sqliteTable("floors", {
@ -8,4 +9,9 @@ export const floors = sqliteTable("floors", {
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
// Optional parent building. Floors created before buildings existed stay
// without one and are shown as "Ohne Gebäude".
buildingId: text("building_id").references(() => buildings.id, {
onDelete: "set null",
}),
});

View file

@ -21,5 +21,9 @@ export const projects = sqliteTable("projects", {
.$type<DistributionBoardSupplyType[]>()
.notNull()
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
siteAddress: text("site_address"),
workStage: text("work_stage"),
projectLead: text("project_lead"),
projectEditor: text("project_editor"),
currentRevision: integer("current_revision").notNull().default(0),
});

View file

@ -1,14 +1,21 @@
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
// The info-card fields were added additively to schema version 1: every
// previously stored command remains valid, and the inverse is still derived
// from the persisted row for exactly the changed fields.
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues {
export interface DistributionBoardUpdateValues
extends Record<DistributionBoardInfoField, string | null> {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
@ -82,16 +89,20 @@ export function assertDistributionBoardUpdateProjectCommand(
for (const change of changes) {
if (
!isPlainObject(change) ||
typeof change.field !== "string" ||
(change.field !== "floorId" &&
change.field !== "supplyType" &&
change.field !== "simultaneityFactor") ||
change.field !== "simultaneityFactor" &&
!isInfoField(change.field)) ||
seen.has(change.field)
) {
throw new Error(
"Distribution-board update contains an invalid or duplicate field."
);
}
if (change.field === "floorId") {
if (isInfoField(change.field)) {
assertInfoFieldValue(change.field, change.value);
} else if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
@ -121,6 +132,29 @@ export function assertDistributionBoardUpdateProjectCommand(
}
}
function isInfoField(field: string): field is DistributionBoardInfoField {
return (distributionBoardInfoFields as readonly string[]).includes(field);
}
function assertInfoFieldValue(
field: DistributionBoardInfoField,
value: unknown
) {
if (value === null) {
return;
}
if (
typeof value !== "string" ||
!value.trim() ||
value !== value.trim() ||
value.length > distributionBoardInfoFieldMaxLengths[field]
) {
throw new Error(
`${field} must be a trimmed, non-empty string of at most ${distributionBoardInfoFieldMaxLengths[field]} characters or null.`
);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -10,7 +10,9 @@ import {
type DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import {
distributionBoardInfoFields,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type {
@ -72,7 +74,7 @@ export interface DistributionBoardSubtreeSnapshot {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
};
} & Record<DistributionBoardInfoField, string | null>;
circuitList: {
id: string;
projectId: string;
@ -169,6 +171,22 @@ export function cloneDistributionBoardSubtree(
return clone;
}
/**
* Subtree commands stored before the board info fields existed carry no
* info values. They are equivalent to a board whose info fields are empty,
* so comparisons against a freshly captured subtree and re-inserts stay
* exact.
*/
export function normalizeDistributionBoardSubtreeSnapshot(
snapshot: DistributionBoardSubtreeSnapshot
): DistributionBoardSubtreeSnapshot {
const board = { ...snapshot.distributionBoard };
for (const field of distributionBoardInfoFields) {
board[field] = board[field] ?? null;
}
return { ...snapshot, distributionBoard: board };
}
export function assertDistributionBoardSubtreeSnapshot(
value: unknown
): asserts value is DistributionBoardSubtreeSnapshot {
@ -204,6 +222,11 @@ export function assertDistributionBoardSubtreeSnapshot(
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
throw new Error("Distribution-board simultaneity factor is invalid.");
}
for (const field of distributionBoardInfoFields) {
if (board[field] !== undefined) {
assertNullableString(board[field], `distributionBoard.${field}`);
}
}
for (const field of [
"id",
"projectId",

View file

@ -1,21 +1,42 @@
import crypto from "node:crypto";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectBuildingInsertCommandType = "project-building.insert" as const;
export const projectBuildingDeleteCommandType = "project-building.delete" as const;
export const projectBuildingUpdateCommandType = "project-building.update" as const;
export const projectFloorInsertCommandType = "project-floor.insert" as const;
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
export const projectFloorUpdateCommandType = "project-floor.update" as const;
export const projectRoomInsertCommandType = "project-room.insert" as const;
export const projectRoomDeleteCommandType = "project-room.delete" as const;
export const projectRoomUpdateCommandType = "project-room.update" as const;
/** Room commands, building commands and legacy floor commands. */
export const projectLocationStructureCommandSchemaVersion = 1 as const;
/**
* Floor commands carry `buildingId` since version 2. Version 1 floor
* commands stay executable from persisted history: they were recorded before
* buildings existed, so they are upgraded with `buildingId = null`.
*/
export const projectFloorCommandSchemaVersion = 2 as const;
const legacyProjectFloorCommandSchemaVersion = 1 as const;
export interface ProjectBuildingSnapshot {
id: string;
projectId: string;
name: string;
sortOrder: number;
}
export interface ProjectFloorSnapshot {
id: string;
projectId: string;
name: string;
sortOrder: number;
buildingId: string | null;
}
export type LegacyProjectFloorSnapshot = Omit<ProjectFloorSnapshot, "buildingId">;
export interface ProjectRoomSnapshot {
id: string;
projectId: string;
@ -24,39 +45,93 @@ export interface ProjectRoomSnapshot {
roomName: string;
}
interface ProjectBuildingStructureCommandPayload {
building: ProjectBuildingSnapshot;
}
interface ProjectFloorStructureCommandPayload {
floor: ProjectFloorSnapshot;
}
interface LegacyProjectFloorStructureCommandPayload {
floor: LegacyProjectFloorSnapshot;
}
interface ProjectRoomStructureCommandPayload {
room: ProjectRoomSnapshot;
}
interface ProjectBuildingUpdateCommandPayload {
expected: ProjectBuildingSnapshot;
target: ProjectBuildingSnapshot;
}
interface ProjectFloorUpdateCommandPayload {
expected: ProjectFloorSnapshot;
target: ProjectFloorSnapshot;
}
interface LegacyProjectFloorUpdateCommandPayload {
expected: LegacyProjectFloorSnapshot;
target: LegacyProjectFloorSnapshot;
}
interface ProjectRoomUpdateCommandPayload {
expected: ProjectRoomSnapshot;
target: ProjectRoomSnapshot;
}
export interface ProjectBuildingInsertProjectCommand
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingInsertCommandType;
}
export interface ProjectBuildingDeleteProjectCommand
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingDeleteCommandType;
}
export interface ProjectBuildingUpdateProjectCommand
extends SerializedProjectCommand<ProjectBuildingUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingUpdateCommandType;
}
export interface ProjectFloorInsertProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface ProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface ProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
export interface LegacyProjectFloorInsertProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface LegacyProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface LegacyProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorUpdateCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
@ -79,23 +154,46 @@ export interface ProjectRoomUpdateProjectCommand
}
export type ProjectLocationStructureProjectCommand =
| ProjectBuildingInsertProjectCommand
| ProjectBuildingDeleteProjectCommand
| ProjectBuildingUpdateProjectCommand
| ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorInsertProjectCommand
| LegacyProjectFloorDeleteProjectCommand
| LegacyProjectFloorUpdateProjectCommand
| ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand;
export function createProjectFloorSnapshot(
export function createProjectBuildingSnapshot(
projectId: string,
name: string,
sortOrder: number
): ProjectBuildingSnapshot {
const building: ProjectBuildingSnapshot = {
id: crypto.randomUUID(),
projectId,
name: name.trim(),
sortOrder,
};
assertProjectBuildingSnapshot(building);
return building;
}
export function createProjectFloorSnapshot(
projectId: string,
name: string,
sortOrder: number,
buildingId: string | null = null
): ProjectFloorSnapshot {
const floor: ProjectFloorSnapshot = {
id: crypto.randomUUID(),
projectId,
name: name.trim(),
sortOrder,
buildingId: buildingId?.trim() || null,
};
assertProjectFloorSnapshot(floor);
return floor;
@ -120,12 +218,46 @@ export function createProjectRoomSnapshot(
return room;
}
export function createProjectBuildingInsertProjectCommand(
building: ProjectBuildingSnapshot
): ProjectBuildingInsertProjectCommand {
assertProjectBuildingSnapshot(building);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectBuildingInsertCommandType,
payload: { building },
};
}
export function createProjectBuildingDeleteProjectCommand(
building: ProjectBuildingSnapshot
): ProjectBuildingDeleteProjectCommand {
assertProjectBuildingSnapshot(building);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectBuildingDeleteCommandType,
payload: { building },
};
}
export function createProjectBuildingUpdateProjectCommand(
expected: ProjectBuildingSnapshot,
target: ProjectBuildingSnapshot
): ProjectBuildingUpdateProjectCommand {
assertMatchingBuildingUpdate(expected, target);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectBuildingUpdateCommandType,
payload: { expected, target },
};
}
export function createProjectFloorInsertProjectCommand(
floor: ProjectFloorSnapshot
): ProjectFloorInsertProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorInsertCommandType,
payload: { floor },
};
@ -136,7 +268,7 @@ export function createProjectFloorDeleteProjectCommand(
): ProjectFloorDeleteProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorDeleteCommandType,
payload: { floor },
};
@ -170,7 +302,7 @@ export function createProjectFloorUpdateProjectCommand(
): ProjectFloorUpdateProjectCommand {
assertMatchingFloorUpdate(expected, target);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorUpdateCommandType,
payload: { expected, target },
};
@ -188,9 +320,43 @@ export function createProjectRoomUpdateProjectCommand(
};
}
export function assertProjectBuildingInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingInsertProjectCommand {
assertProjectBuildingStructureProjectCommand(
command,
projectBuildingInsertCommandType
);
}
export function assertProjectBuildingDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingDeleteProjectCommand {
assertProjectBuildingStructureProjectCommand(
command,
projectBuildingDeleteCommandType
);
}
export function assertProjectBuildingUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingUpdateProjectCommand {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectBuildingUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported project-building update command.");
}
assertMatchingBuildingUpdate(command.payload.expected, command.payload.target);
}
export function assertProjectFloorInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorInsertProjectCommand {
): asserts command is
| ProjectFloorInsertProjectCommand
| LegacyProjectFloorInsertProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorInsertCommandType
@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand(
export function assertProjectFloorDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorDeleteProjectCommand {
): asserts command is
| ProjectFloorDeleteProjectCommand
| LegacyProjectFloorDeleteProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorDeleteCommandType
@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand(
export function assertProjectFloorUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorUpdateProjectCommand {
): asserts command is
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorUpdateProjectCommand {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectFloorUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported project-floor update command.");
}
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
assertMatchingFloorUpdate(
upgradeLegacyFloorSnapshot(command.payload.expected),
upgradeLegacyFloorSnapshot(command.payload.target)
);
return;
}
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
throw new Error("Unsupported project-floor update command.");
}
assertMatchingFloorUpdate(command.payload.expected, command.payload.target);
}
@ -252,6 +431,68 @@ export function assertProjectRoomUpdateProjectCommand(
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
}
/**
* Upgrades a (validated) floor command of any supported version to the
* current version. Version 1 commands predate buildings; their floors had no
* building, so `buildingId` becomes null.
*/
export function upgradeProjectFloorCommand(
command:
| ProjectFloorInsertProjectCommand
| LegacyProjectFloorInsertProjectCommand
): ProjectFloorInsertProjectCommand;
export function upgradeProjectFloorCommand(
command:
| ProjectFloorDeleteProjectCommand
| LegacyProjectFloorDeleteProjectCommand
): ProjectFloorDeleteProjectCommand;
export function upgradeProjectFloorCommand(
command:
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorUpdateProjectCommand
): ProjectFloorUpdateProjectCommand;
export function upgradeProjectFloorCommand(
command:
| ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorInsertProjectCommand
| LegacyProjectFloorDeleteProjectCommand
| LegacyProjectFloorUpdateProjectCommand
) {
if (command.schemaVersion === projectFloorCommandSchemaVersion) {
return command;
}
switch (command.type) {
case projectFloorInsertCommandType:
return createProjectFloorInsertProjectCommand(
upgradeLegacyFloorSnapshot(command.payload.floor)
);
case projectFloorDeleteCommandType:
return createProjectFloorDeleteProjectCommand(
upgradeLegacyFloorSnapshot(command.payload.floor)
);
case projectFloorUpdateCommandType:
return createProjectFloorUpdateProjectCommand(
upgradeLegacyFloorSnapshot(command.payload.expected),
upgradeLegacyFloorSnapshot(command.payload.target)
);
}
}
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
assertProjectBuildingSnapshot(expected);
assertProjectBuildingSnapshot(target);
if (
expected.id !== target.id ||
expected.projectId !== target.projectId ||
expected.sortOrder !== target.sortOrder ||
expected.name === target.name
) {
throw new Error("Project-building update must change only the name.");
}
}
function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
assertProjectFloorSnapshot(expected);
assertProjectFloorSnapshot(target);
@ -259,9 +500,11 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
expected.id !== target.id ||
expected.projectId !== target.projectId ||
expected.sortOrder !== target.sortOrder ||
expected.name === target.name
(expected.name === target.name && expected.buildingId === target.buildingId)
) {
throw new Error("Project-floor update must change only the name.");
throw new Error(
"Project-floor update must change only the name or the building."
);
}
}
@ -277,22 +520,49 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) {
}
}
export function assertProjectBuildingSnapshot(
building: unknown
): asserts building is ProjectBuildingSnapshot {
if (!isPlainObject(building) || Object.keys(building).length !== 4) {
throw new Error("Project-building snapshot is invalid.");
}
assertNormalizedNonEmptyString(building.id, "building.id");
assertNormalizedNonEmptyString(building.projectId, "building.projectId");
assertNormalizedNonEmptyString(building.name, "building.name");
assertSortOrder(building.sortOrder, "building.sortOrder");
}
export function assertProjectFloorSnapshot(
floor: unknown
): asserts floor is ProjectFloorSnapshot {
if (!isPlainObject(floor) || Object.keys(floor).length !== 5) {
throw new Error("Project-floor snapshot is invalid.");
}
assertLegacyFloorFields(floor);
if (floor.buildingId !== null) {
assertNormalizedNonEmptyString(floor.buildingId, "floor.buildingId");
}
}
function assertLegacyProjectFloorSnapshot(
floor: unknown
): asserts floor is LegacyProjectFloorSnapshot {
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
throw new Error("Project-floor snapshot is invalid.");
}
assertLegacyFloorFields(floor);
}
function assertLegacyFloorFields(floor: Record<string, unknown>) {
assertNormalizedNonEmptyString(floor.id, "floor.id");
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
assertNormalizedNonEmptyString(floor.name, "floor.name");
if (
typeof floor.sortOrder !== "number" ||
!Number.isSafeInteger(floor.sortOrder) ||
floor.sortOrder < 0
) {
throw new Error("floor.sortOrder must be a non-negative integer.");
}
assertSortOrder(floor.sortOrder, "floor.sortOrder");
}
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
assertLegacyProjectFloorSnapshot(floor);
return { ...floor, buildingId: null };
}
export function assertProjectRoomSnapshot(
@ -310,11 +580,11 @@ export function assertProjectRoomSnapshot(
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
}
function assertProjectFloorStructureProjectCommand(
function assertProjectBuildingStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectFloorInsertCommandType
| typeof projectFloorDeleteCommandType
| typeof projectBuildingInsertCommandType
| typeof projectBuildingDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
@ -322,6 +592,29 @@ function assertProjectFloorStructureProjectCommand(
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-building structure command.");
}
assertProjectBuildingSnapshot(command.payload.building);
}
function assertProjectFloorStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectFloorInsertCommandType
| typeof projectFloorDeleteCommandType
) {
if (
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-floor structure command.");
}
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
assertLegacyProjectFloorSnapshot(command.payload.floor);
return;
}
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
throw new Error("Unsupported project-floor structure command.");
}
assertProjectFloorSnapshot(command.payload.floor);
@ -344,6 +637,16 @@ function assertProjectRoomStructureProjectCommand(
assertProjectRoomSnapshot(command.payload.room);
}
function assertSortOrder(value: unknown, field: string) {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < 0
) {
throw new Error(`${field} must be a non-negative integer.`);
}
}
function assertNormalizedNonEmptyString(
value: unknown,
field: string

View file

@ -3,13 +3,23 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
export const projectSettingsUpdateCommandType =
"project.update-settings" as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const;
// Version 3 adds the print cover fields. Versions 2 (without cover fields)
// and 1 (additionally without the public-building flag) stay executable;
// their missing values keep the current project values.
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export interface ProjectSettingsValues {
export interface ProjectSettingsValues
extends Record<ProjectCoverField, string | null> {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@ -21,8 +31,13 @@ export interface ProjectSettingsValues {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
type PreviousProjectSettingsValues = Omit<
export type WithoutCoverProjectSettingsValues = Omit<
ProjectSettingsValues,
ProjectCoverField
>;
export type BaselineProjectSettingsValues = Omit<
WithoutCoverProjectSettingsValues,
"isPublicBuilding"
>;
@ -32,15 +47,22 @@ export interface CurrentProjectSettingsUpdateProjectCommand
type: typeof projectSettingsUpdateCommandType;
}
interface PreviousProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
export interface WithoutCoverProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export interface BaselineProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<BaselineProjectSettingsValues> {
schemaVersion: typeof baselineProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export type ProjectSettingsUpdateProjectCommand =
| CurrentProjectSettingsUpdateProjectCommand
| PreviousProjectSettingsUpdateProjectCommand;
| WithoutCoverProjectSettingsUpdateProjectCommand
| BaselineProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand(
values: ProjectSettingsValues
@ -60,7 +82,10 @@ export function assertProjectSettingsUpdateProjectCommand(
if (
command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion)
command.schemaVersion !==
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
baselineProjectSettingsUpdateCommandSchemaVersion)
) {
throw new Error("Unsupported project settings update command.");
}
@ -91,13 +116,23 @@ export function assertProjectSettingsUpdateProjectCommand(
"Project settings update command contains invalid supply types."
);
}
const isPreviousSchema =
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion;
const payload = command.payload;
const expectedKeyCount =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? 8
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? 9
: 9 + projectCoverFields.length;
if (
(isPreviousSchema && Object.keys(command.payload).length !== 8) ||
(!isPreviousSchema &&
(typeof command.payload.isPublicBuilding !== "boolean" ||
Object.keys(command.payload).length !== 9))
Object.keys(payload).length !== expectedKeyCount ||
(command.schemaVersion !==
baselineProjectSettingsUpdateCommandSchemaVersion &&
typeof payload.isPublicBuilding !== "boolean") ||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
!projectCoverFields.every((field) =>
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
))
) {
throw new Error(
"Project settings update command contains invalid values."

View file

@ -3,32 +3,80 @@ import {
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
parseEstimateBalanceSnapshot,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectStateRestoreCommandType = "project.restore-state";
export const projectStateRestoreCommandSchemaVersion = 1 as const;
export const projectStateRestoreCommandSchemaVersion = 2 as const;
const snapshotRestoreCommandSchemaVersion = 1 as const;
export interface ProjectStateRestoreCommandPayload {
expectedStateSha256: string;
targetState: ProjectStateSnapshot;
}
export type ProjectStateRestoreCommand = SerializedProjectCommand<
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
ProjectStateRestoreCommandPayload
> & {
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export interface ProjectTransferRestoreCommandPayload
extends ProjectStateRestoreCommandPayload {
expectedEstimateBalances: EstimateBalanceSnapshot[];
targetEstimateBalances: EstimateBalanceSnapshot[];
}
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
ProjectTransferRestoreCommandPayload
> & {
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export type ProjectStateRestoreCommand =
| SnapshotProjectStateRestoreCommand
| ProjectTransferRestoreCommand;
export function createProjectStateRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot
): ProjectStateRestoreCommand {
const command: ProjectStateRestoreCommand = {
const command: SnapshotProjectStateRestoreCommand = {
schemaVersion: snapshotRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
},
};
assertProjectStateRestoreCommand(command);
return command;
}
export function createProjectTransferRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot,
expectedEstimateBalances: EstimateBalanceSnapshot[],
targetEstimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferRestoreCommand {
const command: ProjectTransferRestoreCommand = {
schemaVersion: projectStateRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
expectedEstimateBalances: parseEstimateBalances(
expectedEstimateBalances,
targetState.project.id
),
targetEstimateBalances: parseEstimateBalances(
targetEstimateBalances,
targetState.project.id
),
},
};
assertProjectStateRestoreCommand(command);
@ -42,10 +90,7 @@ export function assertProjectStateRestoreCommand(
throw new Error("Project state restore command must be an object.");
}
const command = value as Partial<ProjectStateRestoreCommand>;
if (
command.type !== projectStateRestoreCommandType ||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
) {
if (command.type !== projectStateRestoreCommandType) {
throw new Error("Unsupported project state restore command.");
}
if (!command.payload || typeof command.payload !== "object") {
@ -58,5 +103,43 @@ export function assertProjectStateRestoreCommand(
) {
throw new Error("Expected project state checksum is invalid.");
}
parseProjectStateSnapshot(payload.targetState);
const targetState = parseProjectStateSnapshot(payload.targetState);
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
if (Object.keys(payload).length !== 2) {
throw new Error("Snapshot restore payload contains unknown fields.");
}
return;
}
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
throw new Error("Unsupported project state restore command.");
}
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
parseEstimateBalances(
transferPayload.expectedEstimateBalances,
targetState.project.id
);
parseEstimateBalances(
transferPayload.targetEstimateBalances,
targetState.project.id
);
if (Object.keys(payload).length !== 4) {
throw new Error("Project transfer restore payload contains unknown fields.");
}
}
function parseEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
throw new Error("Project transfer contains duplicate estimate balances.");
}
return balances;
}

View file

@ -1,5 +1,13 @@
import { z } from "zod";
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import {
circuitGroupCategories,
} from "../../shared/constants/circuit-group.js";
@ -27,7 +35,9 @@ import {
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 5 as const;
export const projectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const;
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
const previousProjectStateSnapshotSchemaVersion = 4 as const;
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
@ -58,11 +68,31 @@ const previousProjectSchema = z
})
.strict();
const projectSchema = previousProjectSchema
const publicBuildingProjectSchema = previousProjectSchema
.extend({ isPublicBuilding: z.boolean() })
.strict();
const distributionBoardSchema = z
const projectCoverValueSchema = (field: ProjectCoverField) =>
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
// Schema version 6 adds the print cover fields.
const projectSchema = publicBuildingProjectSchema
.extend({
siteAddress: projectCoverValueSchema("siteAddress"),
workStage: projectCoverValueSchema("workStage"),
projectLead: projectCoverValueSchema("projectLead"),
projectEditor: projectCoverValueSchema("projectEditor"),
})
.strict();
const emptyProjectCoverValues = {
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
};
const legacyDistributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
@ -73,6 +103,35 @@ const distributionBoardSchema = z
})
.strict();
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
z
.string()
.trim()
.min(1)
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable();
const distributionBoardSchema = legacyDistributionBoardSchema
.extend({
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
})
.strict();
function withEmptyDistributionBoardInfo(
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
) {
return boards.map((board) => ({
...board,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
}));
}
const circuitListSchema = z
.object({
id: idSchema,
@ -179,7 +238,7 @@ const projectDeviceSchema = z
})
.strict();
const floorSchema = z
const buildingSchema = z
.object({
id: idSchema,
projectId: idSchema,
@ -188,6 +247,19 @@ const floorSchema = z
})
.strict();
const legacyFloorSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const floorSchema = legacyFloorSchema
.extend({ buildingId: idSchema.nullable() })
.strict();
const roomSchema = z
.object({
id: idSchema,
@ -432,11 +504,24 @@ export function parseExternalModelStateSnapshot(
return externalModelStateSchema.parse(value);
}
const projectStateSnapshotContents = {
// Versions 1-6 have floors without a building and no building list.
const previousProjectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(legacyFloorSchema),
rooms: z.array(roomSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
distributionBoardComponentProtectionDevices: z.array(
componentProtectionDeviceSchema
),
};
const projectStateSnapshotContents = {
...previousProjectStateSnapshotContents,
buildings: z.array(buildingSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
@ -457,33 +542,55 @@ export const projectStateSnapshotSchema = z
})
.strict();
const previousProjectStateSnapshotSchema = z
const coverProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
...previousProjectStateSnapshotContents,
})
.strict();
const boardInfoProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
const externalModelProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents,
...previousProjectStateSnapshotContents,
})
.strict();
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...previousProjectStateSnapshotContents,
})
.strict();
@ -491,8 +598,8 @@ const baselineProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
project: previousProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
distributionBoards: z.array(legacyDistributionBoardSchema),
...previousProjectStateSnapshotContents,
})
.strict();
@ -500,6 +607,20 @@ export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
/**
* Stored snapshots of every supported version can be restored: they are
* upgraded to the current version while parsing.
*/
export function isSupportedProjectStateSnapshotSchemaVersion(
schemaVersion: number
): boolean {
return (
Number.isInteger(schemaVersion) &&
schemaVersion >= baselineProjectStateSnapshotSchemaVersion &&
schemaVersion <= projectStateSnapshotSchemaVersion
);
}
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
@ -512,6 +633,28 @@ export function parseProjectStateSnapshot(
}
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const cover = upgradeToCoverProjectStateSnapshot(value);
if (
cover === null ||
typeof cover !== "object" ||
Array.isArray(cover) ||
(cover as { schemaVersion?: unknown }).schemaVersion !==
coverProjectStateSnapshotSchemaVersion
) {
return cover;
}
// Every supported version before 7 lacks buildings; its floors had no
// building.
const previous = coverProjectStateSnapshotSchema.parse(cover);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
buildings: [],
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
};
}
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
if (
value === null ||
typeof value !== "object" ||
@ -521,18 +664,40 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return value;
}
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
// Every supported version before 6 lacks the distribution-board info
// fields and the project cover fields; they are upgraded as empty (null)
// values.
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
};
}
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
};
}
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
const previous = externalModelProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
externalModel: emptyExternalModelState(),
};
}
@ -540,7 +705,11 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const legacy = legacyProjectStateSnapshotSchema.parse(value);
return {
...legacy,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...legacy.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
legacy.distributionBoards
),
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
@ -549,8 +718,15 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const baseline = baselineProjectStateSnapshotSchema.parse(value);
return {
...baseline,
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false },
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: {
...baseline.project,
isPublicBuilding: false,
...emptyProjectCoverValues,
},
distributionBoards: withEmptyDistributionBoardInfo(
baseline.distributionBoards
),
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
@ -649,6 +825,7 @@ function assertProjectStateSnapshotRelations(
snapshot.projectDevices,
"project device"
);
const buildingIds = uniqueIds(snapshot.buildings, "building");
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
@ -727,8 +904,14 @@ function assertProjectStateSnapshotRelations(
);
}
}
for (const building of snapshot.buildings) {
assertProjectOwnership(building.projectId, projectId, "building");
}
for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor");
if (floor.buildingId !== null) {
assertReference(buildingIds, floor.buildingId, "floor building");
}
}
for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room");

View file

@ -3,9 +3,12 @@ import {
projectStateSnapshotSchemaVersion,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectTransferFormat = "leistungsbilanz.project" as const;
export const projectTransferFormatVersion = 1 as const;
export const projectTransferFormatVersion = 2 as const;
const legacyProjectTransferFormatVersion = 1 as const;
export interface ProjectTransferEnvelope {
format: typeof projectTransferFormat;
@ -13,12 +16,14 @@ export interface ProjectTransferEnvelope {
exportedAtIso: string;
payloadSha256: string;
projectState: ProjectStateSnapshot;
estimateBalances: EstimateBalanceSnapshot[];
}
export function createProjectTransferEnvelope(
exportedAtIso: string,
payloadSha256: string,
projectState: ProjectStateSnapshot
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferEnvelope {
return parseProjectTransferEnvelope({
format: projectTransferFormat,
@ -26,6 +31,7 @@ export function createProjectTransferEnvelope(
exportedAtIso,
payloadSha256,
projectState,
estimateBalances,
});
}
@ -37,7 +43,8 @@ export function parseProjectTransferEnvelope(
}
if (
value.format !== projectTransferFormat ||
value.formatVersion !== projectTransferFormatVersion
(value.formatVersion !== legacyProjectTransferFormatVersion &&
value.formatVersion !== projectTransferFormatVersion)
) {
throw new Error("Project transfer format is not supported.");
}
@ -57,7 +64,13 @@ export function parseProjectTransferEnvelope(
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project transfer snapshot version is not supported.");
}
if (Object.keys(value).length !== 5) {
const estimateBalances =
value.formatVersion === legacyProjectTransferFormatVersion
? []
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
const expectedFieldCount =
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
if (Object.keys(value).length !== expectedFieldCount) {
throw new Error("Project transfer file contains unknown fields.");
}
return {
@ -66,9 +79,43 @@ export function parseProjectTransferEnvelope(
exportedAtIso: value.exportedAtIso,
payloadSha256: value.payloadSha256,
projectState,
estimateBalances,
};
}
export function serializeProjectTransferPayload(
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
) {
const parsedState = parseProjectStateSnapshot(projectState);
return JSON.stringify({
projectState: parsedState,
estimateBalances: parseTransferEstimateBalances(
estimateBalances,
parsedState.project.id
),
});
}
export function remapEstimateBalances(
source: EstimateBalanceSnapshot[],
targetProjectId: string,
createId: () => string
): EstimateBalanceSnapshot[] {
return source.map((balance) =>
parseEstimateBalanceSnapshot({
...balance,
id: createId(),
projectId: targetProjectId,
groups: balance.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
})),
})
);
}
export function remapProjectState(
source: ProjectStateSnapshot,
targetProjectId: string,
@ -84,6 +131,7 @@ export function remapProjectState(
createId
);
const deviceIds = idMap(source.projectDevices, createId);
const buildingIds = idMap(source.buildings, createId);
const floorIds = idMap(source.floors, createId);
const roomIds = idMap(source.rooms, createId);
const rowIds = new Map(
@ -242,10 +290,19 @@ export function remapProjectState(
id: requiredId(deviceIds, device.id),
projectId: targetProjectId,
})),
buildings: source.buildings.map((building) => ({
...building,
id: requiredId(buildingIds, building.id),
projectId: targetProjectId,
})),
floors: source.floors.map((floor) => ({
...floor,
id: requiredId(floorIds, floor.id),
projectId: targetProjectId,
buildingId:
floor.buildingId === null
? null
: requiredId(buildingIds, floor.buildingId),
})),
rooms: source.rooms.map((room) => ({
...room,
@ -272,6 +329,37 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
return targetId;
}
function parseTransferEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
assertUniqueIds(
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
"estimate group"
);
assertUniqueIds(
balances.flatMap((balance) =>
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
),
"estimate row"
);
return balances;
}
function assertUniqueIds(ids: string[], label: string) {
if (new Set(ids).size !== ids.length) {
throw new Error(`Project transfer contains a duplicate ${label} id.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -70,12 +70,18 @@ import {
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import {
assertProjectBuildingDeleteProjectCommand,
assertProjectBuildingInsertProjectCommand,
assertProjectBuildingUpdateProjectCommand,
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand,
projectBuildingDeleteCommandType,
projectBuildingInsertCommandType,
projectBuildingUpdateCommandType,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectFloorUpdateCommandType,
@ -188,6 +194,15 @@ import {
externalObjectDeleteCreatedCircuitCommandType,
} from "../models/external-object-new-circuit-project-command.model.js";
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
interface DispatchProjectCommandInput {
projectId: string;
@ -227,7 +242,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
) {}
executeUser(
@ -492,6 +508,18 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectBuildingInsertCommandType: {
assertProjectBuildingInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectBuildingDeleteCommandType: {
assertProjectBuildingDeleteProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectBuildingUpdateCommandType: {
assertProjectBuildingUpdateProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@ -636,6 +664,24 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case estimateBalanceInsertCommandType:
case estimateBalanceDeleteCommandType:
case estimateBalanceUpdateCommandType: {
if (input.command.type === estimateBalanceInsertCommandType) {
assertEstimateBalanceInsertProjectCommand(input.command);
} else if (input.command.type === estimateBalanceDeleteCommandType) {
assertEstimateBalanceDeleteProjectCommand(input.command);
} else {
assertEstimateBalanceUpdateProjectCommand(input.command);
}
if (!this.estimateBalanceStore) {
throw new Error("Estimate-balance store is not available.");
}
return this.estimateBalanceStore.execute({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({

View file

@ -0,0 +1,33 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js";
export const createEstimateBalanceRequestSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
workStage: z.string().trim().max(50).nullable().optional(),
dateIso: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
template: z.enum(["kg400", "empty", "copy"]),
copyFromBalanceId: z.string().trim().min(1).optional(),
})
.strict()
.refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), {
message: "copyFromBalanceId is required for template copy",
path: ["copyFromBalanceId"],
});
export const updateEstimateBalanceRequestSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
// Validated in detail by parseEstimateBalanceSnapshot.
balance: z.unknown(),
})
.strict();
export const deleteEstimateBalanceRequestSchema = z
.object({ expectedRevision: expectedProjectRevisionSchema })
.strict();

View file

@ -0,0 +1,318 @@
import crypto from "node:crypto";
import { z } from "zod";
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
import {
estimateDefaultGroups,
type EstimateBalanceSnapshot,
type EstimateGroupSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
export const estimateBalanceCommandSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1).max(100);
const trimmed = (max: number) =>
z.string().max(max).refine((value) => value === value.trim(), {
message: "must be trimmed",
});
const nullableText = (max: number) =>
trimmed(max)
.refine((value) => value.length > 0, { message: "use null instead of empty" })
.nullable();
const finite = z.number().finite();
export const estimateRowSnapshotSchema = z
.object({
id: idSchema,
sortOrder: z.number().int().min(0),
supply: nullableText(50),
name: trimmed(200),
quantity: finite.min(0),
unit: z.enum(["piece", "area"]),
connection: z.enum(["fixed", "socket"]).nullable(),
powerPerUnitW: finite.min(0),
simultaneityFactor: finite.min(0).max(1),
cosPhi: finite.gt(0).max(1),
safetySupply: z.boolean(),
remark: nullableText(500),
})
.strict();
export const estimateGroupSnapshotSchema = z
.object({
id: idSchema,
costGroup: trimmed(20),
title: trimmed(200).refine((value) => value.length > 0, {
message: "title must not be empty",
}),
editor: nullableText(100),
sortOrder: z.number().int().min(0),
rows: z.array(estimateRowSnapshotSchema).max(1000),
})
.strict();
export const estimateBalanceSnapshotSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: trimmed(200).refine((value) => value.length > 0, {
message: "name must not be empty",
}),
workStage: nullableText(50),
dateIso: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
message: "invalid date",
})
.nullable(),
reserveAvPercent: finite.min(0).max(100),
reserveSvPercent: finite.min(0).max(100),
sortOrder: z.number().int().min(0),
groups: z.array(estimateGroupSnapshotSchema).max(200),
})
.strict()
.superRefine((balance, context) => {
const ids = new Set<string>();
for (const group of balance.groups) {
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
if (ids.has(id)) {
context.addIssue({
code: "custom",
message: `duplicate id ${id}`,
path: ["groups"],
});
}
ids.add(id);
}
}
});
export interface EstimateBalanceInsertProjectCommand
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceInsertCommandType;
}
export interface EstimateBalanceDeleteProjectCommand
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceDeleteCommandType;
}
export interface EstimateBalanceUpdateProjectCommand
extends SerializedProjectCommand<{
expected: EstimateBalanceSnapshot;
target: EstimateBalanceSnapshot;
}> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceUpdateCommandType;
}
export type EstimateBalanceProjectCommand =
| EstimateBalanceInsertProjectCommand
| EstimateBalanceDeleteProjectCommand
| EstimateBalanceUpdateProjectCommand;
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
const parsed = estimateBalanceSnapshotSchema.parse(value);
return normalizeEstimateBalanceSnapshot(parsed);
}
export function normalizeEstimateBalanceSnapshot(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
return {
...balance,
groups: balance.groups.map((group, groupIndex) => ({
...group,
sortOrder: groupIndex,
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
})),
};
}
/** Stable JSON for exact equality checks of two snapshots. */
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
const normalized = normalizeEstimateBalanceSnapshot(balance);
return JSON.stringify({
id: normalized.id,
projectId: normalized.projectId,
name: normalized.name,
workStage: normalized.workStage,
dateIso: normalized.dateIso,
reserveAvPercent: normalized.reserveAvPercent,
reserveSvPercent: normalized.reserveSvPercent,
sortOrder: normalized.sortOrder,
groups: normalized.groups.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: group.rows.map((row) => ({
id: row.id,
sortOrder: row.sortOrder,
supply: row.supply,
name: row.name,
quantity: row.quantity,
unit: row.unit,
connection: row.connection,
powerPerUnitW: row.powerPerUnitW,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
safetySupply: row.safetySupply,
remark: row.remark,
})),
})),
});
}
export function sameEstimateBalance(
left: EstimateBalanceSnapshot,
right: EstimateBalanceSnapshot
): boolean {
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
}
export function createEstimateBalanceSnapshot(input: {
projectId: string;
name: string;
workStage: string | null;
dateIso: string | null;
sortOrder: number;
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
createId?: () => string;
}): EstimateBalanceSnapshot {
const createId = input.createId ?? (() => crypto.randomUUID());
let groups: EstimateGroupSnapshot[] = [];
let reserveAvPercent = 10;
let reserveSvPercent = 0;
if (input.template === "kg400") {
groups = estimateDefaultGroups.map((group, index) => ({
id: createId(),
costGroup: group.costGroup,
title: group.title,
editor: null,
sortOrder: index,
rows: [],
}));
} else if (input.template !== "empty") {
const source = input.template.copyFrom;
reserveAvPercent = source.reserveAvPercent;
reserveSvPercent = source.reserveSvPercent;
groups = source.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
}));
}
return parseEstimateBalanceSnapshot({
id: createId(),
projectId: input.projectId,
name: input.name.trim(),
workStage: input.workStage?.trim() || null,
dateIso: input.dateIso || null,
reserveAvPercent,
reserveSvPercent,
sortOrder: input.sortOrder,
groups,
});
}
export function createEstimateBalanceInsertProjectCommand(
balance: EstimateBalanceSnapshot
): EstimateBalanceInsertProjectCommand {
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceInsertCommandType,
payload: { balance: parseEstimateBalanceSnapshot(balance) },
};
}
export function createEstimateBalanceDeleteProjectCommand(
balance: EstimateBalanceSnapshot
): EstimateBalanceDeleteProjectCommand {
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceDeleteCommandType,
payload: { balance: parseEstimateBalanceSnapshot(balance) },
};
}
export function createEstimateBalanceUpdateProjectCommand(
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
): EstimateBalanceUpdateProjectCommand {
const payload = {
expected: parseEstimateBalanceSnapshot(expected),
target: parseEstimateBalanceSnapshot(target),
};
assertMatchingUpdate(payload.expected, payload.target);
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceUpdateCommandType,
payload,
};
}
function assertMatchingUpdate(
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
if (expected.id !== target.id || expected.projectId !== target.projectId) {
throw new Error("Estimate-balance update must keep id and project.");
}
if (sameEstimateBalance(expected, target)) {
throw new Error("Estimate-balance update must change the balance.");
}
}
function assertCommandShape(
command: SerializedProjectCommand<unknown>,
type: string,
keys: string[]
): Record<string, unknown> {
const payload = command.payload;
if (
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
command.type !== type ||
payload === null ||
typeof payload !== "object" ||
Array.isArray(payload) ||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
) {
throw new Error("Unsupported estimate-balance command.");
}
return payload as Record<string, unknown>;
}
export function assertEstimateBalanceInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceInsertProjectCommand {
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
parseEstimateBalanceSnapshot(payload.balance);
}
export function assertEstimateBalanceDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceDeleteProjectCommand {
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
parseEstimateBalanceSnapshot(payload.balance);
}
export function assertEstimateBalanceUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceUpdateProjectCommand {
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
"expected",
"target",
]);
assertMatchingUpdate(
parseEstimateBalanceSnapshot(payload.expected),
parseEstimateBalanceSnapshot(payload.target)
);
}

View file

@ -0,0 +1,24 @@
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "../../domain/ports/project-revision.store.js";
import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js";
export interface ExecuteEstimateBalanceCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: EstimateBalanceProjectCommand;
}
export interface ExecutedEstimateBalanceCommand {
revision: AppendedProjectRevision;
inverse: EstimateBalanceProjectCommand;
}
export interface EstimateBalanceProjectCommandStore {
execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand;
}

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(

View file

@ -6,6 +6,8 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { BuildingRepository } from "../../db/repositories/building.repository.js";
import { EstimateBalanceRepository } from "../../db/repositories/estimate-balance.repository.js";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
@ -13,6 +15,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db);
@ -25,6 +28,8 @@ export const distributionBoardComponentRepository =
new DistributionBoardComponentRepository(db);
export const distributionBoardRepository =
new DistributionBoardRepository(db);
export const buildingRepository = new BuildingRepository(db);
export const estimateBalanceRepository = new EstimateBalanceRepository(db);
export const floorRepository = new FloorRepository(db);
export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db);
@ -33,3 +38,4 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);

View file

@ -27,6 +27,7 @@ import { ExternalInitialImportProjectCommandRepository } from "../../db/reposito
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
import { EstimateBalanceProjectCommandRepository } from "../../db/repositories/estimate-balance-project-command.repository.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
@ -80,6 +81,8 @@ export const externalObjectNewRowProjectCommandStore =
new ExternalObjectNewRowProjectCommandRepository(db);
export const externalObjectNewCircuitProjectCommandStore =
new ExternalObjectNewCircuitProjectCommandRepository(db);
export const estimateBalanceProjectCommandStore =
new EstimateBalanceProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService(
externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore,
externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore
externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore
);

View file

@ -0,0 +1,111 @@
import type { Request, Response } from "express";
import {
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectBuildingUpdateProjectCommand,
} from "../../domain/models/project-location-structure-project-command.model.js";
import {
createBuildingSchema,
deleteProjectLocationSchema,
updateBuildingSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { buildingRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function listBuildingsByProject(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const result = await buildingRepository.listByProject(projectId);
return res.json(result);
}
export async function createBuilding(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const existingBuildings = await buildingRepository.listByProject(projectId);
const nextSortOrder = existingBuildings.length
? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1
: 0;
const building = createProjectBuildingSnapshot(
projectId,
parsed.data.name,
nextSortOrder
);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude anlegen",
command: createProjectBuildingInsertProjectCommand(building),
});
return res.status(201).json({ ...result, building });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = updateBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
const target = { ...current, name: parsed.data.name };
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude bearbeiten",
command: createProjectBuildingUpdateProjectCommand(current, target),
});
return res.json({ ...result, building: target });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function deleteBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = deleteProjectLocationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude löschen",
command: createProjectBuildingDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}

View file

@ -9,6 +9,7 @@ import {
createDistributionBoardInsertSubtreeProjectCommand,
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import { distributionBoardInfoFields } from "../../shared/constants/distribution-board.js";
import {
copyDistributionBoardSchema,
createDistributionBoardSchema,
@ -64,6 +65,10 @@ export async function createDistributionBoard(req: Request, res: Response) {
distributionBoard: {
...structure.distributionBoard,
simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
},
});
} catch (error) {
@ -86,6 +91,13 @@ export async function updateDistributionBoard(
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
// Info fields are optional in the request; only submitted fields become
// part of the command and therefore of its inverse.
const infoPatch = Object.fromEntries(
distributionBoardInfoFields
.filter((field) => parsed.data[field] !== undefined)
.map((field) => [field, parsed.data[field]])
);
try {
const result = projectCommandService.executeUser({
projectId,
@ -97,6 +109,7 @@ export async function updateDistributionBoard(
floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType,
simultaneityFactor: parsed.data.simultaneityFactor,
...infoPatch,
}
),
});

View file

@ -0,0 +1,100 @@
import type { Request, Response } from "express";
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
import { officeProfileFields } from "../../shared/constants/project-cover.js";
import {
maxDocumentLogoBytes,
updateOfficeProfileSchema,
uploadDocumentLogoSchema,
type DocumentLogoMimeType,
} from "../../shared/validation/document-branding.schemas.js";
import { documentBrandingRepository } from "../composition/application-repositories.js";
const logoKey = "logo";
export function getDocumentBrandingHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
return res.json({
logo: logo ? toLogoMetadata(logo) : null,
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function updateOfficeProfileHandler(req: Request, res: Response) {
const parsed = updateOfficeProfileSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
documentBrandingRepository.setSettings(parsed.data);
return res.json({
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function getDocumentLogoHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
if (!logo) {
return res.status(404).json({ error: "No logo uploaded" });
}
res.setHeader("Content-Type", logo.mimeType);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Content-Type-Options", "nosniff");
// SVG uploads are rendered through <img> only; forbid script execution if
// the file is ever opened directly.
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src data:"
);
return res.send(logo.data);
}
export function uploadDocumentLogoHandler(req: Request, res: Response) {
const parsed = uploadDocumentLogoSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const data = Buffer.from(parsed.data.contentBase64, "base64");
if (data.length === 0 || data.length > maxDocumentLogoBytes) {
return res
.status(400)
.json({ error: "Das Logo darf höchstens 2 MB groß sein." });
}
if (!matchesMimeType(data, parsed.data.mimeType)) {
return res.status(400).json({
error: "Der Dateiinhalt passt nicht zum angegebenen Bildformat.",
});
}
const logo = documentBrandingRepository.upsert({
key: logoKey,
mimeType: parsed.data.mimeType,
fileName: parsed.data.fileName,
data,
});
return res.json({ logo: toLogoMetadata(logo) });
}
export function deleteDocumentLogoHandler(_req: Request, res: Response) {
documentBrandingRepository.delete(logoKey);
return res.status(204).end();
}
function toLogoMetadata(logo: DocumentBrandingAsset) {
return {
mimeType: logo.mimeType,
fileName: logo.fileName,
sizeBytes: logo.data.length,
updatedAtIso: logo.updatedAt.toISOString(),
};
}
function matchesMimeType(data: Buffer, mimeType: DocumentLogoMimeType) {
if (mimeType === "image/png") {
return data
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
}
if (mimeType === "image/jpeg") {
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
}
const head = data.subarray(0, 2048).toString("utf8").toLowerCase();
return head.includes("<svg");
}

View file

@ -0,0 +1,180 @@
import type { Request, Response } from "express";
import { ZodError } from "zod";
import {
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceSnapshot,
createEstimateBalanceUpdateProjectCommand,
parseEstimateBalanceSnapshot,
sameEstimateBalance,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import {
createEstimateBalanceRequestSchema,
deleteEstimateBalanceRequestSchema,
updateEstimateBalanceRequestSchema,
} from "../../estimate-balance/api/estimate-balance-request.schemas.js";
import { compareEstimateWithDetailed } from "../../shared/estimate-balance/estimate-balance.js";
import { estimateBalanceRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
function params(req: Request) {
const { projectId, balanceId } = req.params;
return {
projectId: typeof projectId === "string" ? projectId : null,
balanceId: typeof balanceId === "string" ? balanceId : null,
};
}
export function listEstimateBalances(req: Request, res: Response) {
const { projectId } = params(req);
if (!projectId) {
return res.status(400).json({ error: "Ungültige Projekt-ID." });
}
return res.json(estimateBalanceRepository.listByProject(projectId));
}
export function getEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const balance = estimateBalanceRepository.findById(projectId, balanceId);
if (!balance) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
return res.json(balance);
}
export function createEstimateBalance(req: Request, res: Response) {
const { projectId } = params(req);
if (!projectId) {
return res.status(400).json({ error: "Ungültige Projekt-ID." });
}
const parsed = createEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
let template: Parameters<typeof createEstimateBalanceSnapshot>[0]["template"] =
parsed.data.template === "copy" ? "empty" : parsed.data.template;
if (parsed.data.template === "copy") {
const source = estimateBalanceRepository.findById(
projectId,
parsed.data.copyFromBalanceId!
);
if (!source) {
return res
.status(404)
.json({ error: "Der zu kopierende Stand wurde nicht gefunden." });
}
template = { copyFrom: source };
}
try {
const balance = createEstimateBalanceSnapshot({
projectId,
name: parsed.data.name,
workStage: parsed.data.workStage ?? null,
dateIso: parsed.data.dateIso ?? null,
sortOrder: estimateBalanceRepository.nextSortOrder(projectId),
template,
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz anlegen",
command: createEstimateBalanceInsertProjectCommand(balance),
});
return res.status(201).json({ ...result, balance });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function updateEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = updateEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
let target;
try {
target = parseEstimateBalanceSnapshot(parsed.data.balance);
} catch (error) {
return res.status(400).json({
error: "Die grobe Leistungsbilanz enthält ungültige Werte.",
issues: error instanceof ZodError ? error.issues : undefined,
});
}
if (target.id !== balanceId || target.projectId !== projectId) {
return res
.status(400)
.json({ error: "Die Bilanz gehört nicht zu diesem Projekt oder dieser Adresse." });
}
const current = estimateBalanceRepository.findById(projectId, balanceId);
if (!current) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
if (sameEstimateBalance(current, target)) {
return res.json({ unchanged: true, balance: current });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz bearbeiten",
command: createEstimateBalanceUpdateProjectCommand(current, target),
});
return res.json({
...result,
balance: estimateBalanceRepository.findById(projectId, balanceId),
});
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function deleteEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = deleteEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = estimateBalanceRepository.findById(projectId, balanceId);
if (!current) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz löschen",
command: createEstimateBalanceDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function getEstimateComparison(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const balance = estimateBalanceRepository.findById(projectId, balanceId);
if (!balance) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
return res.json(
compareEstimateWithDetailed(
balance,
estimateBalanceRepository.listDetailedDeviceRowPower(projectId)
)
);
}

View file

@ -42,7 +42,8 @@ export async function createFloor(req: Request, res: Response) {
const floor = createProjectFloorSnapshot(
projectId,
parsed.data.name,
nextSortOrder
nextSortOrder,
parsed.data.buildingId ?? null
);
try {
const result = projectCommandService.executeUser({
@ -70,7 +71,14 @@ export async function updateFloor(req: Request, res: Response) {
if (!current) {
return res.status(404).json({ error: "Floor not found" });
}
const target = { ...current, name: parsed.data.name };
const target = {
...current,
name: parsed.data.name,
buildingId:
parsed.data.buildingId === undefined
? current.buildingId
: parsed.data.buildingId,
};
try {
const result = projectCommandService.executeUser({
projectId,

View file

@ -4,6 +4,10 @@ import {
updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
import {
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { projectRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
@ -46,12 +50,27 @@ export async function updateProjectSettings(req: Request, res: Response) {
}
const { expectedRevision, ...settings } = parsed.data;
const current = await projectRepository.findById(projectId);
if (!current) {
return res.status(404).json({ error: "Project not found" });
}
// Cover fields are optional in the request and keep their current value
// when omitted. `expectedRevision` rejects a project changed meanwhile.
const coverValues = Object.fromEntries(
projectCoverFields.map((field) => [
field,
settings[field] === undefined ? current[field] : settings[field],
])
) as Record<ProjectCoverField, string | null>;
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision,
description: "Projekteinstellungen bearbeiten",
command: createProjectSettingsUpdateProjectCommand(settings),
command: createProjectSettingsUpdateProjectCommand({
...settings,
...coverValues,
}),
});
const project = await projectRepository.findById(projectId);
if (!project) {

View file

@ -1,6 +1,7 @@
import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
import { projectRouter } from "./routes/project.routes.js";
import { cableSizingRouter } from "./routes/cable-sizing.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js";
@ -53,6 +54,7 @@ app.use("/api/cable-sizing", cableSizingRouter);
app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/document-branding", documentBrandingRouter);
app.use(errorMiddleware);

View file

@ -0,0 +1,10 @@
import express from "express";
import * as documentBrandingController from "../controllers/document-branding.controller.js";
export const documentBrandingRouter = express.Router();
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
documentBrandingRouter.put("/office", documentBrandingController.updateOfficeProfileHandler);
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);

View file

@ -13,8 +13,22 @@ import {
updateDistributionBoard,
} from "../controllers/distribution-board.controller.js";
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
import {
createBuilding,
deleteBuilding,
listBuildingsByProject,
updateBuilding,
} from "../controllers/building.controller.js";
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import {
createEstimateBalance,
deleteEstimateBalance,
getEstimateBalance,
getEstimateComparison,
listEstimateBalances,
updateEstimateBalance,
} from "../controllers/estimate-balance.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import {
@ -100,6 +114,10 @@ projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier",
getNextCircuitIdentifier
);
projectRouter.get("/:projectId/buildings", listBuildingsByProject);
projectRouter.post("/:projectId/buildings", createBuilding);
projectRouter.put("/:projectId/buildings/:buildingId", updateBuilding);
projectRouter.delete("/:projectId/buildings/:buildingId", deleteBuilding);
projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor);
@ -108,3 +126,12 @@ projectRouter.get("/:projectId/rooms", listRoomsByProject);
projectRouter.post("/:projectId/rooms", createRoom);
projectRouter.put("/:projectId/rooms/:roomId", updateRoom);
projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom);
projectRouter.get("/:projectId/estimate-balances", listEstimateBalances);
projectRouter.post("/:projectId/estimate-balances", createEstimateBalance);
projectRouter.get("/:projectId/estimate-balances/:balanceId", getEstimateBalance);
projectRouter.put("/:projectId/estimate-balances/:balanceId", updateEstimateBalance);
projectRouter.delete("/:projectId/estimate-balances/:balanceId", deleteEstimateBalance);
projectRouter.get(
"/:projectId/estimate-balances/:balanceId/comparison",
getEstimateComparison
);

View file

@ -24,3 +24,44 @@ export const distributionBoardSupplyTypeLabels: Record<
MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik",
SiBe: "SiBe – Sicherheitsbeleuchtung",
};
/**
* Planner-owned descriptive distribution-board data shown on the board info
* card and in the print view. The values are local planning values. A later
* Revit/CSV round-trip may prefill them from mapped model parameters, but only
* through a previewed, explicitly confirmed project command (see
* docs/spec/revit-csv-integration-requirements.md); imports never overwrite
* them silently.
*
* `fedFrom` is intentionally free text for now. How a board is logically
* linked to its upstream board or feeding circuit is not decided yet.
*/
export const distributionBoardInfoFields = [
"boardNumber",
"location",
"fedFrom",
"remark",
] as const;
export type DistributionBoardInfoField =
(typeof distributionBoardInfoFields)[number];
export const distributionBoardInfoFieldLabels: Record<
DistributionBoardInfoField,
string
> = {
boardNumber: "Verteilernummer",
location: "Örtlichkeit",
fedFrom: "Gespeist von",
remark: "Bemerkung",
};
export const distributionBoardInfoFieldMaxLengths: Record<
DistributionBoardInfoField,
number
> = {
boardNumber: 50,
location: 200,
fedFrom: 200,
remark: 2000,
};

View file

@ -0,0 +1,9 @@
export const documentLogoMimeTypes = [
"image/png",
"image/jpeg",
"image/svg+xml",
] as const;
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
export const maxDocumentLogoBytes = 2 * 1024 * 1024;

View file

@ -0,0 +1,45 @@
/**
* Project data shown on the print view cover page. They are ordinary project
* settings (persisted through `project.update-settings`, part of logical
* snapshots and project transfer).
*/
export const projectCoverFields = [
"siteAddress",
"workStage",
"projectLead",
"projectEditor",
] as const;
export type ProjectCoverField = (typeof projectCoverFields)[number];
export const projectCoverFieldLabels: Record<ProjectCoverField, string> = {
siteAddress: "Anschrift Bauvorhaben",
workStage: "Leistungsphase",
projectLead: "Projektleitung",
projectEditor: "Bearbeitung",
};
export const projectCoverFieldMaxLengths: Record<ProjectCoverField, number> = {
siteAddress: 500,
workStage: 100,
projectLead: 200,
projectEditor: 200,
};
/**
* Office-wide data for the cover page ("Planverfasser"). Global branding like
* the document logo, outside project revisions and snapshots.
*/
export const officeProfileFields = ["officeName", "officeAddress"] as const;
export type OfficeProfileField = (typeof officeProfileFields)[number];
export const officeProfileFieldLabels: Record<OfficeProfileField, string> = {
officeName: "Büro",
officeAddress: "Anschrift Büro",
};
export const officeProfileFieldMaxLengths: Record<OfficeProfileField, number> = {
officeName: 200,
officeAddress: 500,
};

View file

@ -0,0 +1,314 @@
/**
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
*
* Used by the API (validation, comparison) and by the frontend (live totals),
* so this file has no imports: Turbopack resolves frontend imports without
* the `.js` suffix the API build needs.
*/
export type EstimateRowUnit = "piece" | "area";
export type EstimateRowConnection = "fixed" | "socket";
export interface EstimateRowSnapshot {
id: string;
sortOrder: number;
/** Versorgung, e.g. "ELT" or "MSR" (free text). */
supply: string | null;
/** Verbraucher / Fläche. May be empty while a row is being typed. */
name: string;
/** Anzahl (unit "piece") or area in m² (unit "area"). */
quantity: number;
unit: EstimateRowUnit;
connection: EstimateRowConnection | null;
/** W per piece or W/m². */
powerPerUnitW: number;
simultaneityFactor: number;
cosPhi: number;
/** Also needed on the safety power supply (SV). */
safetySupply: boolean;
remark: string | null;
}
export interface EstimateGroupSnapshot {
id: string;
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
costGroup: string;
title: string;
/** Trade or person responsible for the entries ("bearbeitet von"). */
editor: string | null;
sortOrder: number;
rows: EstimateRowSnapshot[];
}
export interface EstimateBalanceSnapshot {
id: string;
projectId: string;
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
name: string;
workStage: string | null;
/** ISO date YYYY-MM-DD. */
dateIso: string | null;
reserveAvPercent: number;
reserveSvPercent: number;
sortOrder: number;
groups: EstimateGroupSnapshot[];
}
/** Default structure of the office template "Leistungsbilanz - KG400". */
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
{ costGroup: "410", title: "Sanitärtechnik" },
{ costGroup: "420", title: "Wärmeversorgung" },
{ costGroup: "430", title: "Lufttechnische Anlagen" },
{ costGroup: "434", title: "Kälteanlagen" },
{ costGroup: "440", title: "Starkstromanlagen" },
{ costGroup: "450", title: "Schwachstromanlagen" },
{ costGroup: "460", title: "Fördertechnik" },
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
{ costGroup: "480", title: "Gebäudeautomation" },
{ costGroup: "", title: "Allgemein" },
];
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
piece: "St.",
area: "m²",
};
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
fixed: "Fest",
socket: "Steckd.",
};
export interface EstimatePower {
kw: number;
kva: number;
}
export interface EstimateRowResult {
av: EstimatePower;
sv: EstimatePower;
}
export interface EstimateGroupResult {
groupId: string;
av: EstimatePower;
sv: EstimatePower;
}
export interface EstimateBalanceResult {
groups: EstimateGroupResult[];
total: { av: EstimatePower; sv: EstimatePower };
reserve: { av: EstimatePower; sv: EstimatePower };
withReserve: { av: EstimatePower; sv: EstimatePower };
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
maxCurrentA: { av: number; sv: number };
}
/**
* Rounds a calculated value for display like the spreadsheet does: binary
* floating point stores 40.175 as 40.17499…, so the scaled value is first
* cut to 6 decimals and only then rounded half away from zero
* (40.175 -> 40.18, -1.825 -> -1.83).
*/
export function roundEstimateValue(value: number, digits = 2): number {
const factor = 10 ** digits;
const scaled = Number((Math.abs(value) * factor).toFixed(6));
return (Math.sign(value) * Math.round(scaled)) / factor;
}
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
}
function scalePower(power: EstimatePower, factor: number): EstimatePower {
return { kw: power.kw * factor, kva: power.kva * factor };
}
/**
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
* AV covers every consumer (normal supply); SV additionally lists the rows
* that must also be supplied by the safety power supply.
*/
export function calculateEstimateRow(
row: Pick<
EstimateRowSnapshot,
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
>
): EstimateRowResult {
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
const av = { kw, kva };
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
}
export function calculateEstimateBalance(
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
threePhaseVoltageV: number
): EstimateBalanceResult {
const groups = balance.groups.map((group) =>
group.rows.reduce<EstimateGroupResult>(
(sum, row) => {
const result = calculateEstimateRow(row);
return {
groupId: group.id,
av: addPower(sum.av, result.av),
sv: addPower(sum.sv, result.sv),
};
},
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
)
);
const total = groups.reduce(
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
{ av: zeroPower(), sv: zeroPower() }
);
const reserve = {
av: scalePower(total.av, balance.reserveAvPercent / 100),
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
};
const withReserve = {
av: addPower(total.av, reserve.av),
sv: addPower(total.sv, reserve.sv),
};
const currentFactor =
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
return {
groups,
total,
reserve,
withReserve,
maxCurrentA: {
av: withReserve.av.kva * currentFactor,
sv: withReserve.sv.kva * currentFactor,
},
};
}
/**
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
* estimate group: a group code with trailing zeros removed is a prefix
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
* no group matches.
*/
export function matchEstimateCostGroup(
detailedCostGroup: string | null,
groupCostGroups: readonly string[]
): string | null {
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
if (!digits) {
return null;
}
let best: { code: string; length: number } | null = null;
for (const code of groupCostGroups) {
const codeDigits = code.replace(/\D/g, "");
const prefix = codeDigits.replace(/0+$/, "");
if (!prefix || !digits.startsWith(prefix)) {
continue;
}
if (!best || prefix.length > best.length) {
best = { code, length: prefix.length };
}
}
return best?.code ?? null;
}
export interface EstimateComparisonGroup {
groupId: string;
costGroup: string;
title: string;
estimatedAv: EstimatePower;
detailed: EstimatePower;
detailedRowCount: number;
deltaKw: number;
/** Relative deviation of the detailed value from the estimate, null without estimate. */
deltaPercent: number | null;
}
export interface EstimateComparison {
groups: EstimateComparisonGroup[];
unassigned: EstimatePower & { rowCount: number };
total: {
estimatedAv: EstimatePower;
detailed: EstimatePower;
deltaKw: number;
deltaPercent: number | null;
};
}
export interface DetailedDeviceRowPower {
costGroup: string | null;
/** Total power of the device row in kW (quantity · power · device GZF). */
kw: number;
cosPhi: number | null;
}
function delta(estimated: number, detailed: number) {
return {
deltaKw: detailed - estimated,
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
};
}
/**
* Compares the estimate per cost group with the detailed device rows of all
* circuit lists. The distribution-board simultaneity factor is not applied
* on either side; a missing cos φ counts as 1.
*/
export function compareEstimateWithDetailed(
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
detailedRows: readonly DetailedDeviceRowPower[]
): EstimateComparison {
const estimate = calculateEstimateBalance(balance, 0);
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
for (const row of detailedRows) {
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
const code = matchEstimateCostGroup(row.costGroup, codes);
const target =
code === null
? unassigned
: detailedByCode.get(code) ??
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
target.kw += row.kw;
target.kva += kva;
target.rowCount += 1;
}
// A cost group code can appear in several groups; the detailed value is
// shown once, on the first group with that code.
const shownCodes = new Set<string>();
const groups = balance.groups.map((group, index) => {
const estimatedAv = estimate.groups[index]!.av;
const detailedEntry =
group.costGroup && !shownCodes.has(group.costGroup)
? detailedByCode.get(group.costGroup)
: undefined;
if (group.costGroup) {
shownCodes.add(group.costGroup);
}
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
return {
groupId: group.id,
costGroup: group.costGroup,
title: group.title,
estimatedAv,
detailed,
detailedRowCount: detailedEntry?.rowCount ?? 0,
...delta(estimatedAv.kw, detailed.kw),
};
});
const detailedTotal = groups.reduce(
(sum, group) => addPower(sum, group.detailed),
{ kw: unassigned.kw, kva: unassigned.kva }
);
return {
groups,
unassigned,
total: {
estimatedAv: estimate.total.av,
detailed: detailedTotal,
...delta(estimate.total.av.kw, detailedTotal.kw),
},
};
}

View file

@ -0,0 +1,42 @@
import { z } from "zod";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../constants/document-branding.js";
import { officeProfileFieldMaxLengths } from "../constants/project-cover.js";
export {
documentLogoMimeTypes,
maxDocumentLogoBytes,
type DocumentLogoMimeType,
} from "../constants/document-branding.js";
export const uploadDocumentLogoSchema = z
.object({
fileName: z.string().trim().min(1).max(200),
mimeType: z.enum(documentLogoMimeTypes),
contentBase64: z
.string()
.min(1)
// Base64 expands by 4/3; the decoded size is checked again server-side.
.max(Math.ceil((maxDocumentLogoBytes * 4) / 3) + 4),
})
.strict();
export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>;
// Empty text is stored as "not set" (null).
const officeValueSchema = (maximumLength: number) =>
z
.string()
.trim()
.max(maximumLength)
.nullable()
.transform((value) => (value ? value : null));
export const updateOfficeProfileSchema = z
.object({
officeName: officeValueSchema(officeProfileFieldMaxLengths.officeName),
officeAddress: officeValueSchema(officeProfileFieldMaxLengths.officeAddress),
})
.strict();

View file

@ -1,7 +1,25 @@
import { z } from "zod";
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../constants/project-cover.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
// Empty text is stored as null so "not entered" has one representation.
const optionalCoverValueSchema = (field: ProjectCoverField) =>
z
.string()
.trim()
.max(projectCoverFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const createProjectSchema = z.object({
name: z.string().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).optional(),
@ -30,6 +48,11 @@ export const updateProjectSettingsSchema = z
.refine((values) => new Set(values).size === values.length, {
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
}),
// Print cover fields; omitted fields keep their current value.
siteAddress: optionalCoverValueSchema("siteAddress"),
workStage: optionalCoverValueSchema("workStage"),
projectLead: optionalCoverValueSchema("projectLead"),
projectEditor: optionalCoverValueSchema("projectEditor"),
})
.strict();
@ -42,12 +65,27 @@ export const createDistributionBoardSchema = z
})
.strict();
// Empty info text is stored as null so that "not entered" has one
// representation in the database, snapshots and print view.
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
z
.string()
.trim()
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const updateDistributionBoardSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes),
simultaneityFactor: z.number().finite().min(0).max(1),
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
})
.strict();
@ -64,10 +102,22 @@ export const deleteDistributionBoardSchema = z
})
.strict();
export const createBuildingSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
})
.strict();
export const updateBuildingSchema = createBuildingSchema;
// A missing buildingId keeps the current value (older clients send only the
// name); null removes the floor from its building.
export const createFloorSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
buildingId: z.string().trim().min(1).nullable().optional(),
})
.strict();