forked from jappel/leistungsbilanz-ts
Merge remote-tracking branch 'origin/main' into feature/cable-sizing
This commit is contained in:
commit
598003554a
136 changed files with 27182 additions and 1669 deletions
1166
src/app/globals.css
1166
src/app/globals.css
File diff suppressed because it is too large
Load diff
|
|
@ -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">
|
||||
|
|
|
|||
745
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
745
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal 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
360
src/app/projects/[projectId]/print/page.tsx
Normal file
360
src/app/projects/[projectId]/print/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createBuilding,
|
||||
createFloor,
|
||||
createRoom,
|
||||
deleteBuilding,
|
||||
deleteFloor,
|
||||
deleteRoom,
|
||||
getProject,
|
||||
listBuildings,
|
||||
listFloors,
|
||||
listRooms,
|
||||
updateBuilding,
|
||||
updateFloor,
|
||||
updateRoom,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
BuildingDto,
|
||||
CreateRoomInput,
|
||||
FloorDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
} from "../../../../frontend/types";
|
||||
import {
|
||||
ALL_SELECTION,
|
||||
UNASSIGNED_BUILDING_LABEL,
|
||||
UNASSIGNED_FLOOR_LABEL,
|
||||
UNASSIGNED_SELECTION,
|
||||
countFloorsByBuilding,
|
||||
countRoomsByFloor,
|
||||
defaultBuildingIdForNewFloor,
|
||||
defaultFloorIdForNewRoom,
|
||||
describeRoomLocation,
|
||||
filterFloorsByBuilding,
|
||||
filterRooms,
|
||||
groupFloorsByBuilding,
|
||||
hasFloorsWithoutBuilding,
|
||||
normalizeBuildingSelection,
|
||||
normalizeFloorSelection,
|
||||
showsRoomsWithoutFloorEntry,
|
||||
type StructureSelection,
|
||||
} from "../../../../frontend/utils/building-structure";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import { RoomModal } from "../../../../frontend/components/room-modal";
|
||||
|
||||
const BUILDING_HAS_FLOORS_MESSAGE =
|
||||
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
|
||||
|
||||
type StructureDialog =
|
||||
| { kind: "building"; building: BuildingDto | null }
|
||||
| { kind: "floor"; floor: FloorDto | null }
|
||||
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
|
||||
|
||||
function formatCount(count: number, singular: string, plural: string) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
function compareRooms(left: RoomDto, right: RoomDto) {
|
||||
return (
|
||||
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
|
||||
left.roomName.localeCompare(right.roomName, "de")
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureColumnProps {
|
||||
addLabel: string;
|
||||
children: ReactNode;
|
||||
count: number;
|
||||
headingId: string;
|
||||
isAddDisabled: boolean;
|
||||
onAdd: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function StructureColumn({
|
||||
addLabel,
|
||||
children,
|
||||
count,
|
||||
headingId,
|
||||
isAddDisabled,
|
||||
onAdd,
|
||||
title,
|
||||
}: StructureColumnProps) {
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<h2 className="structure-column-title" id={headingId}>
|
||||
{title}
|
||||
<span className="badge text-bg-secondary ms-2">{count}</span>
|
||||
</h2>
|
||||
<button
|
||||
aria-label={addLabel}
|
||||
className="btn btn-sm btn-outline-primary structure-icon-button"
|
||||
disabled={isAddDisabled}
|
||||
onClick={onAdd}
|
||||
title={addLabel}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="list-group list-group-flush structure-list">{children}</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface StructureEntryProps {
|
||||
actions?: ReactNode;
|
||||
hint?: string;
|
||||
isSelected?: boolean;
|
||||
label: ReactNode;
|
||||
meta?: string;
|
||||
onSelect?: () => void;
|
||||
}
|
||||
|
||||
function StructureEntry({
|
||||
actions,
|
||||
hint,
|
||||
isSelected = false,
|
||||
label,
|
||||
meta,
|
||||
onSelect,
|
||||
}: StructureEntryProps) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="structure-entry-text">
|
||||
<span className="structure-entry-label">{label}</span>
|
||||
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
|
||||
</span>
|
||||
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={
|
||||
isSelected
|
||||
? "list-group-item structure-entry is-selected"
|
||||
: "list-group-item structure-entry"
|
||||
}
|
||||
>
|
||||
{onSelect ? (
|
||||
<button
|
||||
aria-pressed={isSelected}
|
||||
className="structure-entry-main"
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className="structure-entry-main">{content}</div>
|
||||
)}
|
||||
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface EntryActionsProps {
|
||||
deleteLabel: string;
|
||||
editLabel: string;
|
||||
isDisabled: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function EntryActions({
|
||||
deleteLabel,
|
||||
editLabel,
|
||||
isDisabled,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: EntryActionsProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={editLabel}
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onEdit}
|
||||
title={editLabel}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={deleteLabel}
|
||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||
disabled={isDisabled}
|
||||
onClick={onDelete}
|
||||
title={deleteLabel}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildingStructurePage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
|
||||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||||
const [buildingSelection, setBuildingSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [floorSelection, setFloorSelection] =
|
||||
useState<StructureSelection>(ALL_SELECTION);
|
||||
const [dialog, setDialog] = useState<StructureDialog | null>(null);
|
||||
const [dialogName, setDialogName] = useState("");
|
||||
const [dialogBuildingId, setDialogBuildingId] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const reloadStructure = useCallback(async () => {
|
||||
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
|
||||
await Promise.all([
|
||||
getProject(projectId),
|
||||
listBuildings(projectId),
|
||||
listFloors(projectId),
|
||||
listRooms(projectId),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setBuildings(loadedBuildings);
|
||||
setFloors(loadedFloors);
|
||||
setRooms(loadedRooms);
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadStructure()
|
||||
.then(() => setError(null))
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Gebäudestruktur konnte nicht geladen werden."
|
||||
)
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId, reloadStructure]);
|
||||
|
||||
const effectiveBuildingSelection = normalizeBuildingSelection(
|
||||
buildingSelection,
|
||||
floors,
|
||||
buildings
|
||||
);
|
||||
const effectiveFloorSelection = normalizeFloorSelection(
|
||||
floorSelection,
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
|
||||
const visibleFloors = useMemo(
|
||||
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
|
||||
[buildings, effectiveBuildingSelection, floors]
|
||||
);
|
||||
const visibleFloorGroups = useMemo(
|
||||
() =>
|
||||
effectiveBuildingSelection === ALL_SELECTION && buildings.length
|
||||
? groupFloorsByBuilding(visibleFloors, buildings)
|
||||
: [{ buildingId: null, label: "", floors: visibleFloors }],
|
||||
[buildings, effectiveBuildingSelection, visibleFloors]
|
||||
);
|
||||
const visibleRooms = useMemo(
|
||||
() =>
|
||||
filterRooms(
|
||||
rooms,
|
||||
floors,
|
||||
buildings,
|
||||
effectiveBuildingSelection,
|
||||
effectiveFloorSelection
|
||||
).sort(compareRooms),
|
||||
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
|
||||
);
|
||||
const roomsInBuildingSelection = useMemo(
|
||||
() =>
|
||||
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
|
||||
.length,
|
||||
[buildings, effectiveBuildingSelection, floors, rooms]
|
||||
);
|
||||
const floorCountByBuilding = useMemo(
|
||||
() => countFloorsByBuilding(floors, buildings),
|
||||
[buildings, floors]
|
||||
);
|
||||
const roomCountByFloor = useMemo(
|
||||
() => countRoomsByFloor(rooms, floors),
|
||||
[floors, rooms]
|
||||
);
|
||||
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
|
||||
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
|
||||
rooms,
|
||||
floors,
|
||||
effectiveBuildingSelection
|
||||
);
|
||||
const isBusy = isSaving || !project;
|
||||
|
||||
function selectBuilding(selection: StructureSelection) {
|
||||
setBuildingSelection(selection);
|
||||
setFloorSelection(ALL_SELECTION);
|
||||
}
|
||||
|
||||
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||
execute: (expectedRevision: number) => Promise<TResult>,
|
||||
fallbackMessage: string
|
||||
): Promise<boolean> {
|
||||
if (!project) {
|
||||
return false;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await execute(project.currentRevision);
|
||||
setProject((current) =>
|
||||
current
|
||||
? { ...current, currentRevision: result.history.currentRevision }
|
||||
: current
|
||||
);
|
||||
await reloadStructure();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||
// Refresh the revision and lists so a stale state does not block the
|
||||
// next attempt; the command error stays visible.
|
||||
await reloadStructure().catch(() => undefined);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openBuildingDialog(building: BuildingDto | null) {
|
||||
setDialogName(building?.name ?? "");
|
||||
setDialog({ kind: "building", building });
|
||||
}
|
||||
|
||||
function openFloorDialog(floor: FloorDto | null) {
|
||||
setDialogName(floor?.name ?? "");
|
||||
setDialogBuildingId(
|
||||
floor
|
||||
? floor.buildingId ?? ""
|
||||
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
|
||||
);
|
||||
setDialog({ kind: "floor", floor });
|
||||
}
|
||||
|
||||
function openRoomDialog(room: RoomDto | null) {
|
||||
setDialog({
|
||||
kind: "room",
|
||||
room,
|
||||
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "building" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingBuilding = dialog.building;
|
||||
const name = dialogName.trim();
|
||||
// An unchanged dialog is not a command; the backend rejects no-op updates.
|
||||
if (editingBuilding && editingBuilding.name === name) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingBuilding
|
||||
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
|
||||
: createBuilding(projectId, { name }, expectedRevision),
|
||||
"Gebäude konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (dialog?.kind !== "floor" || !dialogName.trim()) {
|
||||
return;
|
||||
}
|
||||
const editingFloor = dialog.floor;
|
||||
const input = {
|
||||
name: dialogName.trim(),
|
||||
buildingId: dialogBuildingId || null,
|
||||
};
|
||||
if (
|
||||
editingFloor &&
|
||||
editingFloor.name === input.name &&
|
||||
editingFloor.buildingId === input.buildingId
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingFloor
|
||||
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
|
||||
: createFloor(projectId, input, expectedRevision),
|
||||
"Etage konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRoom(input: CreateRoomInput) {
|
||||
if (dialog?.kind !== "room") {
|
||||
return;
|
||||
}
|
||||
const editingRoom = dialog.room;
|
||||
if (
|
||||
editingRoom &&
|
||||
editingRoom.roomNumber === input.roomNumber.trim() &&
|
||||
editingRoom.roomName === input.roomName.trim() &&
|
||||
editingRoom.floorId === (input.floorId || null)
|
||||
) {
|
||||
setDialog(null);
|
||||
return;
|
||||
}
|
||||
const saved = await runCommand(
|
||||
(expectedRevision) =>
|
||||
editingRoom
|
||||
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
|
||||
: createRoom(projectId, input, expectedRevision),
|
||||
"Raum konnte nicht gespeichert werden."
|
||||
);
|
||||
if (saved) {
|
||||
setDialog(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBuilding(building: BuildingDto) {
|
||||
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
|
||||
setError(BUILDING_HAS_FLOORS_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) =>
|
||||
deleteBuilding(projectId, building.id, expectedRevision),
|
||||
"Gebäude konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteFloor(floor: FloorDto) {
|
||||
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
|
||||
"Etage konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteRoom(room: RoomDto) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
|
||||
"Raum konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Projektübersicht"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link"
|
||||
href={`/projects/${projectId}`}
|
||||
title="Zurück zur Projektübersicht"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Gebäudestruktur</h1>
|
||||
<p className="text-secondary mb-0">
|
||||
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Gebäudestruktur wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="structure-columns">
|
||||
<StructureColumn
|
||||
addLabel="Gebäude hinzufügen"
|
||||
count={buildings.length}
|
||||
headingId="structure-buildings-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openBuildingDialog(null)}
|
||||
title="Gebäude"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(floors.length, "Etage", "Etagen")}
|
||||
onSelect={() => selectBuilding(ALL_SELECTION)}
|
||||
/>
|
||||
{buildings.map((building) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Gebäude „${building.name}“ löschen`}
|
||||
editLabel={`Gebäude „${building.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteBuilding(building)}
|
||||
onEdit={() => openBuildingDialog(building)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveBuildingSelection === building.id}
|
||||
key={building.id}
|
||||
label={building.name}
|
||||
meta={formatCount(
|
||||
floorCountByBuilding.get(building.id) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
)}
|
||||
onSelect={() => selectBuilding(building.id)}
|
||||
/>
|
||||
))}
|
||||
{showUnassignedBuilding ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
|
||||
meta={formatCount(
|
||||
floorCountByBuilding.get(null) ?? 0,
|
||||
"Etage",
|
||||
"Etagen"
|
||||
)}
|
||||
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!buildings.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
Noch keine Gebäude vorhanden.
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Etage hinzufügen"
|
||||
count={visibleFloors.length}
|
||||
headingId="structure-floors-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openFloorDialog(null)}
|
||||
title="Etagen"
|
||||
>
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === ALL_SELECTION}
|
||||
label="Alle"
|
||||
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
|
||||
onSelect={() => setFloorSelection(ALL_SELECTION)}
|
||||
/>
|
||||
{visibleFloorGroups.map((group) => (
|
||||
<FloorGroupEntries
|
||||
key={group.buildingId ?? "unassigned"}
|
||||
label={group.label}
|
||||
>
|
||||
{group.floors.map((floor) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Etage „${floor.name}“ löschen`}
|
||||
editLabel={`Etage „${floor.name}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteFloor(floor)}
|
||||
onEdit={() => openFloorDialog(floor)}
|
||||
/>
|
||||
}
|
||||
isSelected={effectiveFloorSelection === floor.id}
|
||||
key={floor.id}
|
||||
label={floor.name}
|
||||
meta={formatCount(
|
||||
roomCountByFloor.get(floor.id) ?? 0,
|
||||
"Raum",
|
||||
"Räume"
|
||||
)}
|
||||
onSelect={() => setFloorSelection(floor.id)}
|
||||
/>
|
||||
))}
|
||||
</FloorGroupEntries>
|
||||
))}
|
||||
{showUnassignedFloor ? (
|
||||
<StructureEntry
|
||||
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
|
||||
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
|
||||
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
|
||||
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
|
||||
/>
|
||||
) : null}
|
||||
{!visibleFloors.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{floors.length
|
||||
? "Keine Etagen in dieser Auswahl."
|
||||
: "Noch keine Etagen vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
|
||||
<StructureColumn
|
||||
addLabel="Raum hinzufügen"
|
||||
count={visibleRooms.length}
|
||||
headingId="structure-rooms-heading"
|
||||
isAddDisabled={isBusy}
|
||||
onAdd={() => openRoomDialog(null)}
|
||||
title="Räume"
|
||||
>
|
||||
{visibleRooms.map((room) => (
|
||||
<StructureEntry
|
||||
actions={
|
||||
<EntryActions
|
||||
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
|
||||
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
|
||||
isDisabled={isBusy}
|
||||
onDelete={() => void handleDeleteRoom(room)}
|
||||
onEdit={() => openRoomDialog(room)}
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
effectiveFloorSelection === ALL_SELECTION
|
||||
? describeRoomLocation(room, floors, buildings)
|
||||
: undefined
|
||||
}
|
||||
key={room.id}
|
||||
label={
|
||||
<>
|
||||
<span className="structure-room-number">{room.roomNumber}</span>{" "}
|
||||
{room.roomName}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!visibleRooms.length && !isLoading ? (
|
||||
<li className="list-group-item structure-empty">
|
||||
{rooms.length
|
||||
? "Keine Räume in dieser Auswahl."
|
||||
: "Noch keine Räume vorhanden."}
|
||||
</li>
|
||||
) : null}
|
||||
</StructureColumn>
|
||||
</div>
|
||||
|
||||
{dialog?.kind === "building" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveBuilding}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
|
||||
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
|
||||
>
|
||||
<label className="form-label" htmlFor="building-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="building-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. Haus A"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "floor" ? (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSubmit={handleSaveFloor}
|
||||
submitDisabled={!dialogName.trim()}
|
||||
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
|
||||
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="floor-name"
|
||||
maxLength={200}
|
||||
onChange={(event) => setDialogName(event.target.value)}
|
||||
placeholder="z. B. EG"
|
||||
required
|
||||
value={dialogName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="floor-building">
|
||||
Gebäude
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="floor-building"
|
||||
onChange={(event) => setDialogBuildingId(event.target.value)}
|
||||
value={dialogBuildingId}
|
||||
>
|
||||
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
|
||||
{buildings.map((building) => (
|
||||
<option key={building.id} value={building.id}>
|
||||
{building.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "room" ? (
|
||||
<RoomModal
|
||||
buildings={buildings}
|
||||
floors={floors}
|
||||
initialRoom={dialog.room ?? undefined}
|
||||
initialValues={
|
||||
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setDialog(null)}
|
||||
onSave={handleSaveRoom}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function FloorGroupEntries({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{label ? (
|
||||
<li className="list-group-item structure-group-label">
|
||||
{label}
|
||||
</li>
|
||||
) : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/db/migrations/0007_board_info_and_document_logo.sql
Normal file
12
src/db/migrations/0007_board_info_and_document_logo.sql
Normal 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;
|
||||
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal file
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal 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;
|
||||
9
src/db/migrations/0009_buildings.sql
Normal file
9
src/db/migrations/0009_buildings.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
CREATE TABLE `buildings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;
|
||||
42
src/db/migrations/0010_estimate_balances.sql
Normal file
42
src/db/migrations/0010_estimate_balances.sql
Normal 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`);
|
||||
2493
src/db/migrations/meta/0007_snapshot.json
Normal file
2493
src/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
27
src/db/repositories/building.repository.ts
Normal file
27
src/db/repositories/building.repository.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { and, asc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { buildings } from "../schema/buildings.js";
|
||||
|
||||
export class BuildingRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
async listByProject(projectId: string) {
|
||||
return this.database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(eq(buildings.projectId, projectId))
|
||||
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
|
||||
}
|
||||
|
||||
async findById(projectId: string, buildingId: string) {
|
||||
return (
|
||||
this.database
|
||||
.select()
|
||||
.from(buildings)
|
||||
.where(
|
||||
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
|
||||
)
|
||||
.get() ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
86
src/db/repositories/document-branding.repository.ts
Normal file
86
src/db/repositories/document-branding.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
121
src/db/repositories/estimate-balance.persistence.ts
Normal 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;
|
||||
}
|
||||
60
src/db/repositories/estimate-balance.repository.ts
Normal file
60
src/db/repositories/estimate-balance.repository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
11
src/db/schema/buildings.ts
Normal file
11
src/db/schema/buildings.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const buildings = sqliteTable("buildings", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
});
|
||||
|
|
@ -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"),
|
||||
});
|
||||
|
||||
|
|
|
|||
18
src/db/schema/document-branding-assets.ts
Normal file
18
src/db/schema/document-branding-assets.ts
Normal 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;
|
||||
13
src/db/schema/document-branding-settings.ts
Normal file
13
src/db/schema/document-branding-settings.ts
Normal 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())`),
|
||||
});
|
||||
60
src/db/schema/estimate-balances.ts
Normal file
60
src/db/schema/estimate-balances.ts
Normal 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)]
|
||||
);
|
||||
|
|
@ -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",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal file
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal 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();
|
||||
318
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
318
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)}
|
||||
|
|
|
|||
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
110
src/frontend/components/distribution-board-info-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/frontend/components/filter-panel.tsx
Normal file
120
src/frontend/components/filter-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/frontend/components/floor-select-options.tsx
Normal file
49
src/frontend/components/floor-select-options.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { BuildingDto, FloorDto } from "../types";
|
||||
import { groupFloorsByBuilding } from "../utils/building-structure";
|
||||
|
||||
export type FloorOption = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
|
||||
interface FloorSelectOptionsProps {
|
||||
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
|
||||
floors: ReadonlyArray<FloorOption>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floor <option>s for a <select>, grouped by building via <optgroup> as soon
|
||||
* as the project has buildings. The caller renders its own empty option
|
||||
* ("Ohne Etage") before these.
|
||||
*/
|
||||
export function FloorSelectOptions({
|
||||
buildings = [],
|
||||
floors,
|
||||
}: FloorSelectOptionsProps) {
|
||||
if (!buildings.length) {
|
||||
return (
|
||||
<>
|
||||
{floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{groupFloorsByBuilding(floors, buildings).map((group) => (
|
||||
<optgroup key={group.buildingId ?? "unassigned"} label={group.label}>
|
||||
{group.floors.map((floor) => (
|
||||
<option key={floor.id} value={floor.id}>
|
||||
{floor.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
src/frontend/components/icon-link.tsx
Normal file
40
src/frontend/components/icon-link.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
127
src/frontend/components/multi-select-dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal 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);
|
||||
}
|
||||
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal 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}`;
|
||||
}
|
||||
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
src/frontend/print/print-document-model.ts
Normal file
191
src/frontend/print/print-document-model.ts
Normal 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;
|
||||
}
|
||||
481
src/frontend/print/print-document.css
Normal file
481
src/frontend/print/print-document.css
Normal 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;
|
||||
}
|
||||
}
|
||||
625
src/frontend/print/print-document.tsx
Normal file
625
src/frontend/print/print-document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
284
src/frontend/utils/building-structure.ts
Normal file
284
src/frontend/utils/building-structure.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// Pure selection, filtering and grouping rules for the building structure
|
||||
// view (Gebäude → Etagen → Räume) and grouped floor selects.
|
||||
import type { BuildingDto, FloorDto, RoomDto } from "../types";
|
||||
|
||||
/** Selection key for "Alle" in the building and floor columns. */
|
||||
export const ALL_SELECTION = "__all__";
|
||||
/** Selection key for "Ohne Gebäude" / "Ohne Etage". */
|
||||
export const UNASSIGNED_SELECTION = "__unassigned__";
|
||||
|
||||
/** ALL_SELECTION, UNASSIGNED_SELECTION or a building/floor id. */
|
||||
export type StructureSelection = string;
|
||||
|
||||
export const UNASSIGNED_BUILDING_LABEL = "Ohne Gebäude";
|
||||
export const UNASSIGNED_FLOOR_LABEL = "Ohne Etage";
|
||||
|
||||
type BuildingLike = Pick<BuildingDto, "id" | "name">;
|
||||
type FloorLike = Pick<FloorDto, "id" | "name"> & {
|
||||
buildingId?: string | null;
|
||||
};
|
||||
type RoomLike = Pick<RoomDto, "floorId">;
|
||||
|
||||
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
|
||||
/** null for floors without (known) building. */
|
||||
buildingId: string | null;
|
||||
label: string;
|
||||
floors: TFloor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The building a floor belongs to, or null when it has none or references a
|
||||
* building that is not (or no longer) loaded.
|
||||
*/
|
||||
export function resolveFloorBuildingId(
|
||||
floor: FloorLike,
|
||||
buildingIds: ReadonlySet<string>
|
||||
): string | null {
|
||||
const buildingId = floor.buildingId ?? null;
|
||||
return buildingId !== null && buildingIds.has(buildingId)
|
||||
? buildingId
|
||||
: null;
|
||||
}
|
||||
|
||||
function toIdSet(entries: ReadonlyArray<{ id: string }>) {
|
||||
return new Set(entries.map((entry) => entry.id));
|
||||
}
|
||||
|
||||
export function hasFloorsWithoutBuilding(
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): boolean {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
return floors.some(
|
||||
(floor) => resolveFloorBuildingId(floor, buildingIds) === null
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRoomsWithoutFloor(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>
|
||||
): boolean {
|
||||
const floorIds = toIdSet(floors);
|
||||
return rooms.some(
|
||||
(room) => room.floorId === null || !floorIds.has(room.floorId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups floors by building in building order; floors without a known
|
||||
* building come last under "Ohne Gebäude". Empty groups are omitted and the
|
||||
* floor order inside a group is preserved.
|
||||
*/
|
||||
export function groupFloorsByBuilding<TFloor extends FloorLike>(
|
||||
floors: ReadonlyArray<TFloor>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): FloorGroup<TFloor>[] {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const floorsByBuilding = new Map<string | null, TFloor[]>();
|
||||
for (const floor of floors) {
|
||||
const buildingId = resolveFloorBuildingId(floor, buildingIds);
|
||||
const group = floorsByBuilding.get(buildingId) ?? [];
|
||||
group.push(floor);
|
||||
floorsByBuilding.set(buildingId, group);
|
||||
}
|
||||
const groups: FloorGroup<TFloor>[] = [];
|
||||
for (const building of buildings) {
|
||||
const groupFloors = floorsByBuilding.get(building.id);
|
||||
if (groupFloors?.length) {
|
||||
groups.push({
|
||||
buildingId: building.id,
|
||||
label: building.name,
|
||||
floors: groupFloors,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unassigned = floorsByBuilding.get(null);
|
||||
if (unassigned?.length) {
|
||||
groups.push({
|
||||
buildingId: null,
|
||||
label: UNASSIGNED_BUILDING_LABEL,
|
||||
floors: unassigned,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Floors shown in the floor column for a building selection. */
|
||||
export function filterFloorsByBuilding<TFloor extends FloorLike>(
|
||||
floors: ReadonlyArray<TFloor>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
buildingSelection: StructureSelection
|
||||
): TFloor[] {
|
||||
if (buildingSelection === ALL_SELECTION) {
|
||||
return [...floors];
|
||||
}
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const targetBuildingId =
|
||||
buildingSelection === UNASSIGNED_SELECTION ? null : buildingSelection;
|
||||
return floors.filter(
|
||||
(floor) => resolveFloorBuildingId(floor, buildingIds) === targetBuildingId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Ohne Etage" is offered only when rooms without floor exist and the
|
||||
* building selection can contain them ("Alle" or "Ohne Gebäude"), because a
|
||||
* room without floor has no building either.
|
||||
*/
|
||||
export function showsRoomsWithoutFloorEntry(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildingSelection: StructureSelection
|
||||
): boolean {
|
||||
return (
|
||||
(buildingSelection === ALL_SELECTION ||
|
||||
buildingSelection === UNASSIGNED_SELECTION) &&
|
||||
hasRoomsWithoutFloor(rooms, floors)
|
||||
);
|
||||
}
|
||||
|
||||
/** Rooms shown in the room column for the current selections. */
|
||||
export function filterRooms<TRoom extends RoomLike>(
|
||||
rooms: ReadonlyArray<TRoom>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
buildingSelection: StructureSelection,
|
||||
floorSelection: StructureSelection
|
||||
): TRoom[] {
|
||||
const floorIds = toIdSet(floors);
|
||||
const isWithoutFloor = (room: RoomLike) =>
|
||||
room.floorId === null || !floorIds.has(room.floorId);
|
||||
|
||||
if (floorSelection === UNASSIGNED_SELECTION) {
|
||||
return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection)
|
||||
? rooms.filter(isWithoutFloor)
|
||||
: [];
|
||||
}
|
||||
if (floorSelection !== ALL_SELECTION) {
|
||||
return rooms.filter((room) => room.floorId === floorSelection);
|
||||
}
|
||||
if (buildingSelection === ALL_SELECTION) {
|
||||
return [...rooms];
|
||||
}
|
||||
const visibleFloorIds = toIdSet(
|
||||
filterFloorsByBuilding(floors, buildings, buildingSelection)
|
||||
);
|
||||
return rooms.filter(
|
||||
(room) =>
|
||||
(room.floorId !== null && visibleFloorIds.has(room.floorId)) ||
|
||||
(buildingSelection === UNASSIGNED_SELECTION && isWithoutFloor(room))
|
||||
);
|
||||
}
|
||||
|
||||
/** Falls back to "Alle" when a selected building vanished. */
|
||||
export function normalizeBuildingSelection(
|
||||
selection: StructureSelection,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): StructureSelection {
|
||||
if (selection === ALL_SELECTION) {
|
||||
return selection;
|
||||
}
|
||||
if (selection === UNASSIGNED_SELECTION) {
|
||||
return hasFloorsWithoutBuilding(floors, buildings)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
return buildings.some((building) => building.id === selection)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
|
||||
/** Falls back to "Alle" when a selected floor is no longer visible. */
|
||||
export function normalizeFloorSelection(
|
||||
selection: StructureSelection,
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>,
|
||||
buildingSelection: StructureSelection
|
||||
): StructureSelection {
|
||||
if (selection === ALL_SELECTION) {
|
||||
return selection;
|
||||
}
|
||||
if (selection === UNASSIGNED_SELECTION) {
|
||||
return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
return filterFloorsByBuilding(floors, buildings, buildingSelection).some(
|
||||
(floor) => floor.id === selection
|
||||
)
|
||||
? selection
|
||||
: ALL_SELECTION;
|
||||
}
|
||||
|
||||
/** Building preselected in the floor dialog ("" = Ohne Gebäude). */
|
||||
export function defaultBuildingIdForNewFloor(
|
||||
buildingSelection: StructureSelection
|
||||
): string {
|
||||
return buildingSelection === ALL_SELECTION ||
|
||||
buildingSelection === UNASSIGNED_SELECTION
|
||||
? ""
|
||||
: buildingSelection;
|
||||
}
|
||||
|
||||
/** Floor preselected in the room dialog ("" = Ohne Etage). */
|
||||
export function defaultFloorIdForNewRoom(
|
||||
floorSelection: StructureSelection
|
||||
): string {
|
||||
return floorSelection === ALL_SELECTION ||
|
||||
floorSelection === UNASSIGNED_SELECTION
|
||||
? ""
|
||||
: floorSelection;
|
||||
}
|
||||
|
||||
export function countFloorsByBuilding(
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): Map<string | null, number> {
|
||||
const buildingIds = toIdSet(buildings);
|
||||
const counts = new Map<string | null, number>();
|
||||
for (const floor of floors) {
|
||||
const buildingId = resolveFloorBuildingId(floor, buildingIds);
|
||||
counts.set(buildingId, (counts.get(buildingId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Room counts per floor id; rooms without known floor count under null. */
|
||||
export function countRoomsByFloor(
|
||||
rooms: ReadonlyArray<RoomLike>,
|
||||
floors: ReadonlyArray<FloorLike>
|
||||
): Map<string | null, number> {
|
||||
const floorIds = toIdSet(floors);
|
||||
const counts = new Map<string | null, number>();
|
||||
for (const room of rooms) {
|
||||
const floorId =
|
||||
room.floorId !== null && floorIds.has(room.floorId)
|
||||
? room.floorId
|
||||
: null;
|
||||
counts.set(floorId, (counts.get(floorId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */
|
||||
export function describeRoomLocation(
|
||||
room: RoomLike,
|
||||
floors: ReadonlyArray<FloorLike>,
|
||||
buildings: ReadonlyArray<BuildingLike>
|
||||
): string {
|
||||
const floor =
|
||||
room.floorId === null
|
||||
? undefined
|
||||
: floors.find((entry) => entry.id === room.floorId);
|
||||
if (!floor) {
|
||||
return UNASSIGNED_FLOOR_LABEL;
|
||||
}
|
||||
const buildingId = resolveFloorBuildingId(floor, toIdSet(buildings));
|
||||
const building =
|
||||
buildingId === null
|
||||
? undefined
|
||||
: buildings.find((entry) => entry.id === buildingId);
|
||||
return building ? `${floor.name} · ${building.name}` : floor.name;
|
||||
}
|
||||
27
src/frontend/utils/client-id.ts
Normal file
27
src/frontend/utils/client-id.ts
Normal 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("-");
|
||||
}
|
||||
249
src/frontend/utils/overview-filters.ts
Normal file
249
src/frontend/utils/overview-filters.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
111
src/server/controllers/building.controller.ts
Normal file
111
src/server/controllers/building.controller.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { Request, Response } from "express";
|
||||
import {
|
||||
createProjectBuildingDeleteProjectCommand,
|
||||
createProjectBuildingInsertProjectCommand,
|
||||
createProjectBuildingSnapshot,
|
||||
createProjectBuildingUpdateProjectCommand,
|
||||
} from "../../domain/models/project-location-structure-project-command.model.js";
|
||||
import {
|
||||
createBuildingSchema,
|
||||
deleteProjectLocationSchema,
|
||||
updateBuildingSchema,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { buildingRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
export async function listBuildingsByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid projectId" });
|
||||
}
|
||||
|
||||
const result = await buildingRepository.listByProject(projectId);
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
export async function createBuilding(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
if (typeof projectId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid projectId" });
|
||||
}
|
||||
|
||||
const parsed = createBuildingSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const existingBuildings = await buildingRepository.listByProject(projectId);
|
||||
const nextSortOrder = existingBuildings.length
|
||||
? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1
|
||||
: 0;
|
||||
const building = createProjectBuildingSnapshot(
|
||||
projectId,
|
||||
parsed.data.name,
|
||||
nextSortOrder
|
||||
);
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Gebäude anlegen",
|
||||
command: createProjectBuildingInsertProjectCommand(building),
|
||||
});
|
||||
return res.status(201).json({ ...result, building });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateBuilding(req: Request, res: Response) {
|
||||
const { projectId, buildingId } = req.params;
|
||||
if (typeof projectId !== "string" || typeof buildingId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
const parsed = updateBuildingSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const current = await buildingRepository.findById(projectId, buildingId);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: "Building not found" });
|
||||
}
|
||||
const target = { ...current, name: parsed.data.name };
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Gebäude bearbeiten",
|
||||
command: createProjectBuildingUpdateProjectCommand(current, target),
|
||||
});
|
||||
return res.json({ ...result, building: target });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBuilding(req: Request, res: Response) {
|
||||
const { projectId, buildingId } = req.params;
|
||||
if (typeof projectId !== "string" || typeof buildingId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
const parsed = deleteProjectLocationSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const current = await buildingRepository.findById(projectId, buildingId);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: "Building not found" });
|
||||
}
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Gebäude löschen",
|
||||
command: createProjectBuildingDeleteProjectCommand(current),
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
),
|
||||
});
|
||||
|
|
|
|||
100
src/server/controllers/document-branding.controller.ts
Normal file
100
src/server/controllers/document-branding.controller.ts
Normal 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");
|
||||
}
|
||||
180
src/server/controllers/estimate-balance.controller.ts
Normal file
180
src/server/controllers/estimate-balance.controller.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
10
src/server/routes/document-branding.routes.ts
Normal file
10
src/server/routes/document-branding.routes.ts
Normal 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);
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
9
src/shared/constants/document-branding.ts
Normal file
9
src/shared/constants/document-branding.ts
Normal 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;
|
||||
45
src/shared/constants/project-cover.ts
Normal file
45
src/shared/constants/project-cover.ts
Normal 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,
|
||||
};
|
||||
314
src/shared/estimate-balance/estimate-balance.ts
Normal file
314
src/shared/estimate-balance/estimate-balance.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
42
src/shared/validation/document-branding.schemas.ts
Normal file
42
src/shared/validation/document-branding.schemas.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue