Add the rough power balance module for early design phases
In LPH 2/3 there are no circuits yet, but the office needs a power balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz - KG400": each trade enters consumers by piece or by area (W/m²) with GLF and cos phi; the balance sums AV and SV per cost group, adds a reserve and derives the maximum current. The module is separate from the circuit structure: own tables (migration 0010), own folder src/estimate-balance and an import-free shared calculation used by API and UI. Writes go through project commands (estimate-balance.insert/.update/.delete with complete documents), so revision checks and Undo/Redo apply; it is not part of project snapshots or transfer yet. A comparison endpoint sums the device rows of all circuit lists by cost group (longest prefix) against the estimate. The UI adds a sidebar entry, a list of Stände (template KG 400, empty or copy) and a spreadsheet-like editor with autosave, AMEV picker for the GLF, totals, reserve, maximum current, comparison table and print layout. The tests reproduce the template example exactly (143.24 kW, 148.48 kVA, 157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See docs/estimate-balance.md and issue #7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
80dbc73230
commit
37a70b6b36
37 changed files with 8537 additions and 4 deletions
743
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
743
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
"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 {
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue