The print of the rough power balance was plain grey. It now follows the kbp design of the print view: a title block with office logo, stand, project, number, owner, address, work stage, date, editor and office; header row in kbp blue, cost-group rows tinted with a lilac rule, bold subtotals, the total including reserve as a blue band, colored deviations in the comparison, Arial, exact print colors, and a footer with project, stand and "Seite x von y" on every page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
745 lines
26 KiB
TypeScript
745 lines
26 KiB
TypeScript
"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>
|
||
);
|
||
}
|