diff --git a/AGENTS.md b/AGENTS.md index e9db70a..eae68d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -663,6 +663,13 @@ for the early-phase power balance by DIN 276 cost group (see debounced (one Undo step per save), keeps invalid raw input local, and on a 409 reloads only if the balance itself changed; otherwise it re-saves with the new revision. `request()` throws `ApiRequestError` (status, code). +- A single estimate row can be copied explicitly through the existing + ProjectDevice dialog. The dialog is only a prefilled proposal; creation + happens after confirmation through `project-device.insert`, with the current + project revision and normal Undo/Redo. The copy stores no link back to the + estimate row, never places a circuit row automatically, and does not change + estimate, snapshot or transfer schemas. Area rows require manual quantity + and power because W/m² remains outside the ProjectDevice model. ## Persistence and Migration Rules diff --git a/docs/estimate-balance.md b/docs/estimate-balance.md index b8b675b..c68758d 100644 --- a/docs/estimate-balance.md +++ b/docs/estimate-balance.md @@ -9,7 +9,10 @@ Issue #7. Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt -nichts in Stromkreislisten, Verteilungen oder Projektgeräte. +nichts automatisch in Stromkreislisten, Verteilungen oder Projektgeräte. Eine +ausdrückliche Einzelaktion kann die Werte einer Grobbilanz-Zeile als Vorschlag +in den bestehenden Projektgeräte-Dialog übernehmen; erst dessen Bestätigung +legt über `project-device.insert` ein unplatziertes Projektgerät an. ## Aufbau @@ -67,6 +70,35 @@ Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1. Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %. +## Übernahme als Projektgerät + +Die Zeilenaktion „Als Projektgerät übernehmen“ ist eine einmalige, ausdrücklich +bestätigte Kopie. Vor dem Öffnen des Dialogs werden ausstehende Änderungen der +Grobbilanz gespeichert. Das Anlegen läuft anschließend mit der aktuellen +Projektrevision über den normalen Projektgeräte-Befehl und ist dadurch wie ein +manuell angelegtes Projektgerät rückgängig machbar. + +- Name und Anzeigename kommen aus dem Zeilennamen; Kostengruppe, GZF, cos φ und + Anmerkung werden vorgeschlagen. `fixed` und `socket` erscheinen als + „Festanschluss“ und „Steckdose“. +- W werden für Stückzeilen in kW umgerechnet. Die Projektgeräte-Anzahl ist 1, + weil das Projektgerät als Vorlage für eine spätere Gerätezeile dient; die + ursprüngliche Stückzahl bleibt im Herkunftshinweis sichtbar. +- Die Kategorie ist mangels Phaseninformation in der Grobbilanz mit + „1-phasig“ vorbelegt und muss im Dialog fachlich geprüft werden. +- Flächenzeilen dürfen übernommen werden. Anzahl und Leistung je Stück bleiben + leer und müssen manuell angegeben werden, da das Projektgerät noch keine + Einheit W/m² kennt (siehe Issue #20). +- SV wird nur im Herkunftshinweis gezeigt. Die tatsächliche Versorgungsart + gehört zur Verteilung. +- Gleichnamige vorhandene Projektgeräte erzeugen eine Warnung, sperren das + Anlegen aber nicht. + +Zwischen Grobbilanz-Zeile und Projektgerät wird keine Verbindung gespeichert. +Es gibt keine Stapelübernahme und keine automatische Platzierung in einer +Verteilung oder einem Stromkreis. Snapshot-, Transfer- und +Grobbilanz-Befehlsformate ändern sich dadurch nicht. + ## Befehle, Undo/Redo Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit diff --git a/src/app/globals.css b/src/app/globals.css index e5474b8..de46d37 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2141,7 +2141,7 @@ button.structure-entry-main:focus-visible { .estimate-col-result { width: 4.6rem; } .estimate-col-sv { width: 3.2rem; } .estimate-col-remark { width: 9rem; } -.estimate-col-actions { width: 2.4rem; } +.estimate-col-actions { width: 4.5rem; } .estimate-cell-input, .estimate-cell-select { @@ -2286,6 +2286,13 @@ button.structure-entry-main:focus-visible { gap: 2px; } +.estimate-row-actions { + display: flex; + justify-content: center; + gap: 2px; + white-space: nowrap; +} + .estimate-group-title-row .estimate-actions-cell { width: auto; white-space: nowrap; diff --git a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx index 584f5cf..933449d 100644 --- a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx +++ b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx @@ -14,11 +14,13 @@ import { } from "lucide-react"; import { ApiRequestError, + createProjectDevice, getEstimateBalance, getEstimateComparison, getProject, getProjectHistory, isRevisionConflictError, + listProjectDevices, redoProjectCommand, undoProjectCommand, updateEstimateBalance, @@ -26,11 +28,17 @@ import { import type { EstimateBalanceDto, EstimateComparisonDto, + CreateProjectDeviceInput, ProjectDto, + ProjectDeviceDto, ProjectHistoryStateDto, } from "../../../../../frontend/types"; -import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance"; +import type { + EstimateGroupSnapshot, + EstimateRowSnapshot, +} from "../../../../../shared/estimate-balance/estimate-balance"; import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal"; +import { ProjectDeviceModal } from "../../../../../frontend/components/project-device-modal"; import { addEstimateGroup, estimateBalanceFingerprint, @@ -41,7 +49,11 @@ import { updateEstimateRow, validateEstimateBalance, } from "../../../../../frontend/estimate-balance/estimate-balance-document"; -import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format"; +import { + formatGermanNumber, + formatIsoDateGerman, +} from "../../../../../frontend/estimate-balance/estimate-balance-format"; +import { createEstimateProjectDeviceInitialValues } from "../../../../../frontend/estimate-balance/estimate-project-device-prefill"; import { EstimateBalanceSheet, type EstimateBalanceUpdate, @@ -60,6 +72,10 @@ const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu ge type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid"; type GroupDialog = { group: EstimateGroupSnapshot | null }; +type ProjectDeviceDialog = { + group: EstimateGroupSnapshot; + row: EstimateRowSnapshot; +}; function errorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; @@ -94,6 +110,11 @@ export default function EstimateBalanceEditorPage() { const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>( null ); + const [projectDeviceDialog, setProjectDeviceDialog] = + useState(null); + const [projectDevices, setProjectDevices] = useState([]); + const [isPreparingProjectDevice, setIsPreparingProjectDevice] = useState(false); + const [isSavingProjectDevice, setIsSavingProjectDevice] = useState(false); // Refs carry the latest values into timers, unload handlers and chained // async saves without re-creating them. @@ -456,7 +477,77 @@ export default function EstimateBalanceEditorPage() { setGzfTarget(null); } - const isEditingDisabled = !draft || isHistoryBusy; + async function handleOpenProjectDevice(groupId: string, rowId: string) { + setError(null); + setNotice(null); + setIsPreparingProjectDevice(true); + try { + if (!(await saveNow())) { + setError( + "Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor ein Projektgerät angelegt werden kann." + ); + return; + } + const current = draftRef.current; + const group = current?.groups.find((entry) => entry.id === groupId); + const row = group?.rows.find((entry) => entry.id === rowId); + if (!group || !row) { + setError("Die gewählte Grobbilanz-Zeile ist nicht mehr vorhanden."); + return; + } + const existingDevices = await listProjectDevices(projectId); + setProjectDevices(existingDevices); + setProjectDeviceDialog({ group, row }); + } catch (err) { + setError( + errorMessage(err, "Der Projektgeräte-Dialog konnte nicht vorbereitet werden.") + ); + } finally { + setIsPreparingProjectDevice(false); + } + } + + async function handleCreateProjectDevice(input: CreateProjectDeviceInput) { + const revision = revisionRef.current; + if (!projectDeviceDialog || revision === null) return; + setError(null); + setIsSavingProjectDevice(true); + try { + if (!(await saveNow())) { + setError( + "Die Grobbilanz muss zuerst fehlerfrei gespeichert werden, bevor das Projektgerät angelegt werden kann." + ); + return; + } + const currentRevision = revisionRef.current; + if (currentRevision === null) return; + const result = await createProjectDevice(projectId, input, currentRevision); + revisionRef.current = result.history.currentRevision; + setHistory(result.history); + setProject((currentProject) => + currentProject + ? { ...currentProject, currentRevision: result.history.currentRevision } + : currentProject + ); + setProjectDevices((current) => [...current, result.projectDevice]); + setProjectDeviceDialog(null); + setNotice(`Projektgerät „${result.projectDevice.displayName}“ wurde angelegt.`); + } catch (err) { + if (isRevisionConflictError(err)) { + await reloadAll().catch(handleReloadError); + setError( + "Der Projektstand hat sich zwischenzeitlich geändert. Der aktuelle Stand wurde geladen; bitte das Projektgerät erneut speichern." + ); + } else { + setError(errorMessage(err, "Das Projektgerät konnte nicht angelegt werden.")); + } + } finally { + setIsSavingProjectDevice(false); + } + } + + const isEditingDisabled = + !draft || isHistoryBusy || isPreparingProjectDevice || isSavingProjectDevice; const invalidCount = invalidFieldKeys.size; const listHref = `/projects/${projectId}/grobbilanz`; @@ -623,6 +714,9 @@ export default function EstimateBalanceEditorPage() { onChange={handleChange} onEditGroup={(group) => setGroupDialog({ group })} onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })} + onTakeOverRow={(groupId, rowId) => + void handleOpenProjectDevice(groupId, rowId) + } onValidityChange={handleValidityChange} threePhaseVoltageV={project.threePhaseVoltageV} /> @@ -692,10 +786,67 @@ export default function EstimateBalanceEditorPage() { {gzfTarget ? ( setGzfTarget(null)} onSelect={handleSelectGzf} /> ) : null} + {projectDeviceDialog ? ( + + } + existingDevices={projectDevices} + globalDevices={[]} + initialValues={createEstimateProjectDeviceInitialValues( + projectDeviceDialog.group, + projectDeviceDialog.row + )} + isSaving={isSavingProjectDevice} + onClose={() => setProjectDeviceDialog(null)} + onImportGlobal={async () => undefined} + onSave={handleCreateProjectDevice} + /> + ) : null} ); } +function EstimateProjectDeviceContext({ + balanceName, + group, + row, +}: { + balanceName: string; + group: EstimateGroupSnapshot; + row: EstimateRowSnapshot; +}) { + const originalUnit = row.unit === "area" ? "m²" : "St."; + const powerUnit = row.unit === "area" ? "W/m²" : "W"; + return ( + <> +
+ Herkunft: Stand „{balanceName}“, Kostengruppe{" "} + {group.costGroup || "ohne Nummer"}. +
+
+ Originalwerte: {formatGermanNumber(row.quantity, 0, 3)} {originalUnit} ×{" "} + {formatGermanNumber(row.powerPerUnitW, 0, 3)} {powerUnit}, GZF{" "} + {formatGermanNumber(row.simultaneityFactor, 0, 3)}, cos φ{" "} + {formatGermanNumber(row.cosPhi, 0, 3)}. +
+
+ {row.unit === "area" + ? "Das Projektgerät dient als Vorlage. Für Flächenwerte sind Anzahl und Leistung je Stück manuell einzutragen." + : "Das Projektgerät ist eine Vorlage mit Anzahl 1."}{" "} + Kategorie bitte prüfen. + {row.safetySupply + ? " SV ist nur ein Hinweis; die Versorgungsart wird an der Verteilung festgelegt." + : ""} +
+ + ); +} + function SaveStatusBadge({ error, invalidCount, diff --git a/src/frontend/components/project-device-modal.tsx b/src/frontend/components/project-device-modal.tsx index 27c24d0..0d95223 100644 --- a/src/frontend/components/project-device-modal.tsx +++ b/src/frontend/components/project-device-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { FormEvent, useState } from "react"; +import React, { FormEvent, type ReactNode, useState } from "react"; import type { CreateProjectDeviceInput, GlobalDeviceDto, @@ -14,10 +14,20 @@ import { } from "../../shared/constants/circuit-group"; import { GzfPickerModal } from "./gzf-picker-modal"; +export type ProjectDeviceModalInitialValues = Omit< + Partial, + "quantity" | "powerPerUnit" +> & { + quantity?: number | null; + powerPerUnit?: number | null; +}; + interface ProjectDeviceModalProps { + context?: ReactNode; + existingDevices?: ReadonlyArray>; globalDevices: GlobalDeviceDto[]; initialDevice?: ProjectDeviceDto; - initialValues?: Partial; + initialValues?: ProjectDeviceModalInitialValues; isSaving: boolean; onClose: () => void; onImportGlobal: (globalDeviceId: string) => Promise; @@ -25,6 +35,8 @@ interface ProjectDeviceModalProps { } export function ProjectDeviceModal({ + context, + existingDevices = [], globalDevices, initialDevice, initialValues, @@ -36,6 +48,11 @@ export function ProjectDeviceModal({ const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues)); const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState(""); const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false); + const hasDuplicateName = hasExistingProjectDeviceName( + values.name, + values.displayName, + existingDevices + ); function update(key: keyof typeof values, value: string) { setValues((current) => ({ ...current, [key]: value })); @@ -79,6 +96,17 @@ export function ProjectDeviceModal({ submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"} title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"} > + {context ? ( +
+ {context} +
+ ) : null} + {hasDuplicateName ? ( +
+ Ein Projektgerät mit gleichem internen Namen oder Anzeigenamen ist bereits vorhanden. + Das Anlegen bleibt möglich. +
+ ) : null} {!initialDevice && globalDevices.length > 0 ? (