"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(null); const [draft, setDraft] = useState(null); const [history, setHistory] = useState(null); const [comparison, setComparison] = useState(null); const [comparisonError, setComparisonError] = useState(null); const [isComparisonOpen, setIsComparisonOpen] = useState(false); const [saveStatus, setSaveStatus] = useState("saved"); const [saveError, setSaveError] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isMissing, setIsMissing] = useState(false); const [isHistoryBusy, setIsHistoryBusy] = useState(false); const [invalidFieldKeys, setInvalidFieldKeys] = useState>( () => new Set() ); const [groupDialog, setGroupDialog] = useState(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(null); const revisionRef = useRef(null); const savedFingerprintRef = useRef(null); const saveInFlightRef = useRef | null>(null); const saveTimerRef = useRef | 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 => { 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) { 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 (
{project && draft ? : null}
void handleBackClick(event)} title="Zurück zur Übersicht der Stände" >
void saveNow()} status={saveStatus} />
{notice ? (
{notice}
) : null} {error ? (
{error}
) : null} {isLoading ? (

Grobe Leistungsbilanz wird geladen …

) : null} {isMissing ? (
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen rückgängig gemacht wurde.{" "} Zur Übersicht der Stände
) : null} {draft && project ? ( <>
Projekt {project.name}
setGroupDialog({ group: null })} onChange={handleChange} onEditGroup={(group) => setGroupDialog({ group })} onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })} onValidityChange={handleValidityChange} threePhaseVoltageV={project.threePhaseVoltageV} />
{isComparisonOpen ? ( ) : null}
{isComparisonOpen ? (
{comparisonError ? (
{comparisonError}
) : null} {comparison ? : null}

Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl × Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne Verteiler-GZF.

) : null}
) : null} {groupDialog ? ( setGroupDialog(null)} onSave={handleSaveGroup} /> ) : null} {gzfTarget ? ( setGzfTarget(null)} onSelect={handleSelectGzf} /> ) : null}
); } 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 (
{label} {invalidCount ? ( {invalidCount === 1 ? "1 ungültige Eingabe wird nicht gespeichert" : `${invalidCount} ungültige Eingaben werden nicht gespeichert`} ) : null} {status === "error" ? ( ) : null}
); }