Add estimate row project device handoff

This commit is contained in:
Julian Appel 2026-09-27 18:36:48 +02:00
parent 8b1923e8ec
commit a6500e2b26
9 changed files with 434 additions and 21 deletions

View file

@ -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;

View file

@ -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<ProjectDeviceDialog | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
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 ? (
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
) : null}
{projectDeviceDialog ? (
<ProjectDeviceModal
context={
<EstimateProjectDeviceContext
balanceName={draft?.name ?? ""}
group={projectDeviceDialog.group}
row={projectDeviceDialog.row}
/>
}
existingDevices={projectDevices}
globalDevices={[]}
initialValues={createEstimateProjectDeviceInitialValues(
projectDeviceDialog.group,
projectDeviceDialog.row
)}
isSaving={isSavingProjectDevice}
onClose={() => setProjectDeviceDialog(null)}
onImportGlobal={async () => undefined}
onSave={handleCreateProjectDevice}
/>
) : null}
</main>
);
}
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 (
<>
<div>
<strong>Herkunft:</strong> Stand „{balanceName}“, Kostengruppe{" "}
{group.costGroup || "ohne Nummer"}.
</div>
<div>
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)}.
</div>
<div className="mt-1">
{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."
: ""}
</div>
</>
);
}
function SaveStatusBadge({
error,
invalidCount,

View file

@ -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<CreateProjectDeviceInput>,
"quantity" | "powerPerUnit"
> & {
quantity?: number | null;
powerPerUnit?: number | null;
};
interface ProjectDeviceModalProps {
context?: ReactNode;
existingDevices?: ReadonlyArray<Pick<ProjectDeviceDto, "name" | "displayName">>;
globalDevices: GlobalDeviceDto[];
initialDevice?: ProjectDeviceDto;
initialValues?: Partial<CreateProjectDeviceInput>;
initialValues?: ProjectDeviceModalInitialValues;
isSaving: boolean;
onClose: () => void;
onImportGlobal: (globalDeviceId: string) => Promise<void>;
@ -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 ? (
<div className="alert alert-info small" role="note">
{context}
</div>
) : null}
{hasDuplicateName ? (
<div className="alert alert-warning small" role="status">
Ein Projektgerät mit gleichem internen Namen oder Anzeigenamen ist bereits vorhanden.
Das Anlegen bleibt möglich.
</div>
) : null}
{!initialDevice && globalDevices.length > 0 ? (
<div className="border rounded p-3 mb-4">
<label className="form-label" htmlFor="global-project-device">
@ -287,7 +315,7 @@ function NumberField({
function toFormValues(
device?: ProjectDeviceDto,
initialValues?: Partial<CreateProjectDeviceInput>
initialValues?: ProjectDeviceModalInitialValues
) {
return {
name: device?.name ?? initialValues?.name ?? "",
@ -295,14 +323,24 @@ function toFormValues(
connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "",
costGroup: device?.costGroup ?? initialValues?.costGroup ?? "",
category: device?.category ?? initialValues?.category ?? "single_phase",
quantity: String(device?.quantity ?? initialValues?.quantity ?? 1),
powerPerUnit: String(device?.powerPerUnit ?? initialValues?.powerPerUnit ?? 0.1),
quantity: toInitialNumber(device?.quantity, initialValues?.quantity, 1),
powerPerUnit: toInitialNumber(device?.powerPerUnit, initialValues?.powerPerUnit, 0.1),
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
remark: device?.remark ?? initialValues?.remark ?? "",
};
}
function toInitialNumber(
deviceValue: number | undefined,
initialValue: number | null | undefined,
fallback: number
) {
if (deviceValue !== undefined) return String(deviceValue);
if (initialValue === null) return "";
return String(initialValue ?? fallback);
}
function optionalString(value: string) {
return value.trim() || undefined;
}
@ -315,7 +353,29 @@ function parseDecimal(value: string) {
return Number(value.trim().replace(",", "."));
}
function isNumberInRange(value: string, min: number, max = Number.POSITIVE_INFINITY) {
export function isProjectDeviceNumberInRange(
value: string,
min: number,
max = Number.POSITIVE_INFINITY
) {
if (!value.trim()) return false;
const parsed = parseDecimal(value);
return Number.isFinite(parsed) && parsed >= min && parsed <= max;
}
const isNumberInRange = isProjectDeviceNumberInRange;
export function hasExistingProjectDeviceName(
name: string,
displayName: string,
existingDevices: ReadonlyArray<Pick<ProjectDeviceDto, "name" | "displayName">>
) {
const candidates = new Set(
[name, displayName].map((value) => value.trim().toLocaleLowerCase("de-DE")).filter(Boolean)
);
return existingDevices.some((device) =>
[device.name, device.displayName].some((value) =>
candidates.has(value.trim().toLocaleLowerCase("de-DE"))
)
);
}

View file

@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import { ArrowDown, ArrowUp, PackagePlus, Pencil, Plus, Trash2 } from "lucide-react";
import {
calculateEstimateBalance,
calculateEstimateRow,
@ -46,6 +46,7 @@ interface EstimateBalanceSheetProps {
onChange: EstimateBalanceUpdate;
onEditGroup: (group: EstimateGroupSnapshot) => void;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onTakeOverRow: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
threePhaseVoltageV: number;
}
@ -178,6 +179,7 @@ function EstimateRowLine({
group,
onChange,
onOpenGzfPicker,
onTakeOverRow,
onValidityChange,
row,
rowIndex,
@ -186,6 +188,7 @@ function EstimateRowLine({
group: EstimateGroupSnapshot;
onChange: EstimateBalanceUpdate;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onTakeOverRow: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
row: EstimateRowSnapshot;
rowIndex: number;
@ -348,14 +351,24 @@ function EstimateRowLine({
/>
</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>
<div className="estimate-row-actions">
<IconButton
disabled={disabled}
label={`Als Projektgerät übernehmen (${rowLabel})`}
onClick={() => onTakeOverRow(group.id, row.id)}
variant="primary"
>
<PackagePlus aria-hidden="true" size={13} />
</IconButton>
<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>
</div>
</td>
</tr>
);
@ -373,6 +386,7 @@ export function EstimateBalanceSheet({
onChange,
onEditGroup,
onOpenGzfPicker,
onTakeOverRow,
onValidityChange,
threePhaseVoltageV,
}: EstimateBalanceSheetProps) {
@ -546,6 +560,7 @@ export function EstimateBalanceSheet({
key={row.id}
onChange={onChange}
onOpenGzfPicker={onOpenGzfPicker}
onTakeOverRow={onTakeOverRow}
onValidityChange={onValidityChange}
row={row}
rowIndex={rowIndex}

View file

@ -0,0 +1,51 @@
import type { CreateProjectDeviceInput } from "../types";
import type {
EstimateGroupSnapshot,
EstimateRowSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
export type EstimateProjectDeviceInitialValues = Omit<
Partial<CreateProjectDeviceInput>,
"quantity" | "powerPerUnit"
> & {
quantity?: number | null;
powerPerUnit?: number | null;
};
const connectionLabels = {
fixed: "Festanschluss",
socket: "Steckdose",
} as const;
/**
* Builds the editable ProjectDevice proposal for one estimate row. Area rows
* deliberately leave quantity and power blank because W/m² has no equivalent
* in the current ProjectDevice model.
*/
export function createEstimateProjectDeviceInitialValues(
group: Pick<EstimateGroupSnapshot, "costGroup">,
row: Pick<
EstimateRowSnapshot,
| "name"
| "unit"
| "connection"
| "powerPerUnitW"
| "simultaneityFactor"
| "cosPhi"
| "remark"
>
): EstimateProjectDeviceInitialValues {
const name = row.name.trim();
return {
name,
displayName: name,
category: "single_phase",
connectionKind: row.connection ? connectionLabels[row.connection] : undefined,
costGroup: group.costGroup || undefined,
quantity: row.unit === "area" ? null : 1,
powerPerUnit: row.unit === "area" ? null : row.powerPerUnitW / 1000,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
remark: row.remark ?? undefined,
};
}