forked from jappel/leistungsbilanz-ts
Add estimate row project device handoff
This commit is contained in:
parent
8b1923e8ec
commit
a6500e2b26
9 changed files with 434 additions and 21 deletions
|
|
@ -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
|
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
|
409 reloads only if the balance itself changed; otherwise it re-saves with
|
||||||
the new revision. `request()` throws `ApiRequestError` (status, code).
|
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
|
## Persistence and Migration Rules
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,10 @@ Issue #7.
|
||||||
|
|
||||||
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
|
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
|
||||||
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
|
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
|
## 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.
|
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 %.
|
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
|
## Befehle, Undo/Redo
|
||||||
|
|
||||||
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
|
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
|
||||||
|
|
|
||||||
|
|
@ -2141,7 +2141,7 @@ button.structure-entry-main:focus-visible {
|
||||||
.estimate-col-result { width: 4.6rem; }
|
.estimate-col-result { width: 4.6rem; }
|
||||||
.estimate-col-sv { width: 3.2rem; }
|
.estimate-col-sv { width: 3.2rem; }
|
||||||
.estimate-col-remark { width: 9rem; }
|
.estimate-col-remark { width: 9rem; }
|
||||||
.estimate-col-actions { width: 2.4rem; }
|
.estimate-col-actions { width: 4.5rem; }
|
||||||
|
|
||||||
.estimate-cell-input,
|
.estimate-cell-input,
|
||||||
.estimate-cell-select {
|
.estimate-cell-select {
|
||||||
|
|
@ -2286,6 +2286,13 @@ button.structure-entry-main:focus-visible {
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.estimate-row-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.estimate-group-title-row .estimate-actions-cell {
|
.estimate-group-title-row .estimate-actions-cell {
|
||||||
width: auto;
|
width: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
|
createProjectDevice,
|
||||||
getEstimateBalance,
|
getEstimateBalance,
|
||||||
getEstimateComparison,
|
getEstimateComparison,
|
||||||
getProject,
|
getProject,
|
||||||
getProjectHistory,
|
getProjectHistory,
|
||||||
isRevisionConflictError,
|
isRevisionConflictError,
|
||||||
|
listProjectDevices,
|
||||||
redoProjectCommand,
|
redoProjectCommand,
|
||||||
undoProjectCommand,
|
undoProjectCommand,
|
||||||
updateEstimateBalance,
|
updateEstimateBalance,
|
||||||
|
|
@ -26,11 +28,17 @@ import {
|
||||||
import type {
|
import type {
|
||||||
EstimateBalanceDto,
|
EstimateBalanceDto,
|
||||||
EstimateComparisonDto,
|
EstimateComparisonDto,
|
||||||
|
CreateProjectDeviceInput,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
|
ProjectDeviceDto,
|
||||||
ProjectHistoryStateDto,
|
ProjectHistoryStateDto,
|
||||||
} from "../../../../../frontend/types";
|
} 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 { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
||||||
|
import { ProjectDeviceModal } from "../../../../../frontend/components/project-device-modal";
|
||||||
import {
|
import {
|
||||||
addEstimateGroup,
|
addEstimateGroup,
|
||||||
estimateBalanceFingerprint,
|
estimateBalanceFingerprint,
|
||||||
|
|
@ -41,7 +49,11 @@ import {
|
||||||
updateEstimateRow,
|
updateEstimateRow,
|
||||||
validateEstimateBalance,
|
validateEstimateBalance,
|
||||||
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
} 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 {
|
import {
|
||||||
EstimateBalanceSheet,
|
EstimateBalanceSheet,
|
||||||
type EstimateBalanceUpdate,
|
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 SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
||||||
|
|
||||||
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
||||||
|
type ProjectDeviceDialog = {
|
||||||
|
group: EstimateGroupSnapshot;
|
||||||
|
row: EstimateRowSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string) {
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
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>(
|
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
||||||
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
|
// Refs carry the latest values into timers, unload handlers and chained
|
||||||
// async saves without re-creating them.
|
// async saves without re-creating them.
|
||||||
|
|
@ -456,7 +477,77 @@ export default function EstimateBalanceEditorPage() {
|
||||||
setGzfTarget(null);
|
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 invalidCount = invalidFieldKeys.size;
|
||||||
const listHref = `/projects/${projectId}/grobbilanz`;
|
const listHref = `/projects/${projectId}/grobbilanz`;
|
||||||
|
|
||||||
|
|
@ -623,6 +714,9 @@ export default function EstimateBalanceEditorPage() {
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onEditGroup={(group) => setGroupDialog({ group })}
|
onEditGroup={(group) => setGroupDialog({ group })}
|
||||||
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
||||||
|
onTakeOverRow={(groupId, rowId) =>
|
||||||
|
void handleOpenProjectDevice(groupId, rowId)
|
||||||
|
}
|
||||||
onValidityChange={handleValidityChange}
|
onValidityChange={handleValidityChange}
|
||||||
threePhaseVoltageV={project.threePhaseVoltageV}
|
threePhaseVoltageV={project.threePhaseVoltageV}
|
||||||
/>
|
/>
|
||||||
|
|
@ -692,10 +786,67 @@ export default function EstimateBalanceEditorPage() {
|
||||||
{gzfTarget ? (
|
{gzfTarget ? (
|
||||||
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
||||||
) : null}
|
) : 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>
|
</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({
|
function SaveStatusBadge({
|
||||||
error,
|
error,
|
||||||
invalidCount,
|
invalidCount,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { FormEvent, useState } from "react";
|
import React, { FormEvent, type ReactNode, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
CreateProjectDeviceInput,
|
CreateProjectDeviceInput,
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
|
|
@ -14,10 +14,20 @@ import {
|
||||||
} from "../../shared/constants/circuit-group";
|
} from "../../shared/constants/circuit-group";
|
||||||
import { GzfPickerModal } from "./gzf-picker-modal";
|
import { GzfPickerModal } from "./gzf-picker-modal";
|
||||||
|
|
||||||
|
export type ProjectDeviceModalInitialValues = Omit<
|
||||||
|
Partial<CreateProjectDeviceInput>,
|
||||||
|
"quantity" | "powerPerUnit"
|
||||||
|
> & {
|
||||||
|
quantity?: number | null;
|
||||||
|
powerPerUnit?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface ProjectDeviceModalProps {
|
interface ProjectDeviceModalProps {
|
||||||
|
context?: ReactNode;
|
||||||
|
existingDevices?: ReadonlyArray<Pick<ProjectDeviceDto, "name" | "displayName">>;
|
||||||
globalDevices: GlobalDeviceDto[];
|
globalDevices: GlobalDeviceDto[];
|
||||||
initialDevice?: ProjectDeviceDto;
|
initialDevice?: ProjectDeviceDto;
|
||||||
initialValues?: Partial<CreateProjectDeviceInput>;
|
initialValues?: ProjectDeviceModalInitialValues;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
||||||
|
|
@ -25,6 +35,8 @@ interface ProjectDeviceModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectDeviceModal({
|
export function ProjectDeviceModal({
|
||||||
|
context,
|
||||||
|
existingDevices = [],
|
||||||
globalDevices,
|
globalDevices,
|
||||||
initialDevice,
|
initialDevice,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
|
@ -36,6 +48,11 @@ export function ProjectDeviceModal({
|
||||||
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
|
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
|
||||||
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
||||||
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
|
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
|
||||||
|
const hasDuplicateName = hasExistingProjectDeviceName(
|
||||||
|
values.name,
|
||||||
|
values.displayName,
|
||||||
|
existingDevices
|
||||||
|
);
|
||||||
|
|
||||||
function update(key: keyof typeof values, value: string) {
|
function update(key: keyof typeof values, value: string) {
|
||||||
setValues((current) => ({ ...current, [key]: value }));
|
setValues((current) => ({ ...current, [key]: value }));
|
||||||
|
|
@ -79,6 +96,17 @@ export function ProjectDeviceModal({
|
||||||
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
|
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
|
||||||
title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"}
|
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 ? (
|
{!initialDevice && globalDevices.length > 0 ? (
|
||||||
<div className="border rounded p-3 mb-4">
|
<div className="border rounded p-3 mb-4">
|
||||||
<label className="form-label" htmlFor="global-project-device">
|
<label className="form-label" htmlFor="global-project-device">
|
||||||
|
|
@ -287,7 +315,7 @@ function NumberField({
|
||||||
|
|
||||||
function toFormValues(
|
function toFormValues(
|
||||||
device?: ProjectDeviceDto,
|
device?: ProjectDeviceDto,
|
||||||
initialValues?: Partial<CreateProjectDeviceInput>
|
initialValues?: ProjectDeviceModalInitialValues
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
name: device?.name ?? initialValues?.name ?? "",
|
name: device?.name ?? initialValues?.name ?? "",
|
||||||
|
|
@ -295,14 +323,24 @@ function toFormValues(
|
||||||
connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "",
|
connectionKind: device?.connectionKind ?? initialValues?.connectionKind ?? "",
|
||||||
costGroup: device?.costGroup ?? initialValues?.costGroup ?? "",
|
costGroup: device?.costGroup ?? initialValues?.costGroup ?? "",
|
||||||
category: device?.category ?? initialValues?.category ?? "single_phase",
|
category: device?.category ?? initialValues?.category ?? "single_phase",
|
||||||
quantity: String(device?.quantity ?? initialValues?.quantity ?? 1),
|
quantity: toInitialNumber(device?.quantity, initialValues?.quantity, 1),
|
||||||
powerPerUnit: String(device?.powerPerUnit ?? initialValues?.powerPerUnit ?? 0.1),
|
powerPerUnit: toInitialNumber(device?.powerPerUnit, initialValues?.powerPerUnit, 0.1),
|
||||||
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
|
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
|
||||||
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
|
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
|
||||||
remark: device?.remark ?? initialValues?.remark ?? "",
|
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) {
|
function optionalString(value: string) {
|
||||||
return value.trim() || undefined;
|
return value.trim() || undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +353,29 @@ function parseDecimal(value: string) {
|
||||||
return Number(value.trim().replace(",", "."));
|
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);
|
const parsed = parseDecimal(value);
|
||||||
return Number.isFinite(parsed) && parsed >= min && parsed <= max;
|
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"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
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 {
|
import {
|
||||||
calculateEstimateBalance,
|
calculateEstimateBalance,
|
||||||
calculateEstimateRow,
|
calculateEstimateRow,
|
||||||
|
|
@ -46,6 +46,7 @@ interface EstimateBalanceSheetProps {
|
||||||
onChange: EstimateBalanceUpdate;
|
onChange: EstimateBalanceUpdate;
|
||||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
||||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||||
|
onTakeOverRow: (groupId: string, rowId: string) => void;
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +179,7 @@ function EstimateRowLine({
|
||||||
group,
|
group,
|
||||||
onChange,
|
onChange,
|
||||||
onOpenGzfPicker,
|
onOpenGzfPicker,
|
||||||
|
onTakeOverRow,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
row,
|
row,
|
||||||
rowIndex,
|
rowIndex,
|
||||||
|
|
@ -186,6 +188,7 @@ function EstimateRowLine({
|
||||||
group: EstimateGroupSnapshot;
|
group: EstimateGroupSnapshot;
|
||||||
onChange: EstimateBalanceUpdate;
|
onChange: EstimateBalanceUpdate;
|
||||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||||
|
onTakeOverRow: (groupId: string, rowId: string) => void;
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||||
row: EstimateRowSnapshot;
|
row: EstimateRowSnapshot;
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
|
|
@ -348,6 +351,15 @@ function EstimateRowLine({
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="estimate-actions-cell estimate-no-print">
|
<td className="estimate-actions-cell estimate-no-print">
|
||||||
|
<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
|
<IconButton
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label={`Zeile löschen (${rowLabel})`}
|
label={`Zeile löschen (${rowLabel})`}
|
||||||
|
|
@ -356,6 +368,7 @@ function EstimateRowLine({
|
||||||
>
|
>
|
||||||
<Trash2 aria-hidden="true" size={13} />
|
<Trash2 aria-hidden="true" size={13} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -373,6 +386,7 @@ export function EstimateBalanceSheet({
|
||||||
onChange,
|
onChange,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onOpenGzfPicker,
|
onOpenGzfPicker,
|
||||||
|
onTakeOverRow,
|
||||||
onValidityChange,
|
onValidityChange,
|
||||||
threePhaseVoltageV,
|
threePhaseVoltageV,
|
||||||
}: EstimateBalanceSheetProps) {
|
}: EstimateBalanceSheetProps) {
|
||||||
|
|
@ -546,6 +560,7 @@ export function EstimateBalanceSheet({
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onOpenGzfPicker={onOpenGzfPicker}
|
onOpenGzfPicker={onOpenGzfPicker}
|
||||||
|
onTakeOverRow={onTakeOverRow}
|
||||||
onValidityChange={onValidityChange}
|
onValidityChange={onValidityChange}
|
||||||
row={row}
|
row={row}
|
||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
getTodayIsoDate,
|
getTodayIsoDate,
|
||||||
parseGermanDecimal,
|
parseGermanDecimal,
|
||||||
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
|
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
|
||||||
|
import { createEstimateProjectDeviceInitialValues } from "../src/frontend/estimate-balance/estimate-project-device-prefill.js";
|
||||||
import {
|
import {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
createEstimateBalance,
|
createEstimateBalance,
|
||||||
|
|
@ -264,6 +265,55 @@ describe("estimate balance document editing", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("estimate row to project device prefill", () => {
|
||||||
|
it("maps a piece row to one reusable project-device template", () => {
|
||||||
|
const balance = sampleBalance();
|
||||||
|
const group = balance.groups[0]!;
|
||||||
|
const row = {
|
||||||
|
...group.rows[0]!,
|
||||||
|
name: " Hebeanlage ",
|
||||||
|
quantity: 4,
|
||||||
|
connection: "fixed" as const,
|
||||||
|
powerPerUnitW: 1250,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
remark: "bauseits",
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(createEstimateProjectDeviceInitialValues(group, row), {
|
||||||
|
name: "Hebeanlage",
|
||||||
|
displayName: "Hebeanlage",
|
||||||
|
category: "single_phase",
|
||||||
|
connectionKind: "Festanschluss",
|
||||||
|
costGroup: "410",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 1.25,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.9,
|
||||||
|
remark: "bauseits",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves quantity and power blank for an area row", () => {
|
||||||
|
const balance = sampleBalance();
|
||||||
|
const group = balance.groups[1]!;
|
||||||
|
const row = {
|
||||||
|
...createEstimateRow(0, () => "area-row"),
|
||||||
|
name: "Bürofläche",
|
||||||
|
unit: "area" as const,
|
||||||
|
connection: "socket" as const,
|
||||||
|
quantity: 250,
|
||||||
|
powerPerUnitW: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initial = createEstimateProjectDeviceInitialValues(group, row);
|
||||||
|
assert.equal(initial.quantity, null);
|
||||||
|
assert.equal(initial.powerPerUnit, null);
|
||||||
|
assert.equal(initial.connectionKind, "Steckdose");
|
||||||
|
assert.equal(initial.costGroup, "440");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("estimate balance saving", () => {
|
describe("estimate balance saving", () => {
|
||||||
it("builds the trimmed server document with null for empty optional texts", () => {
|
it("builds the trimmed server document with null for empty optional texts", () => {
|
||||||
let balance = sampleBalance();
|
let balance = sampleBalance();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { describe, it } from "node:test";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
||||||
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
|
import {
|
||||||
|
hasExistingProjectDeviceName,
|
||||||
|
isProjectDeviceNumberInRange,
|
||||||
|
ProjectDeviceModal,
|
||||||
|
} from "../src/frontend/components/project-device-modal.js";
|
||||||
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-modal.js";
|
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-modal.js";
|
||||||
import type {
|
import type {
|
||||||
ProjectRevisionSummaryDto,
|
ProjectRevisionSummaryDto,
|
||||||
|
|
@ -324,6 +328,42 @@ describe("project device modal presentation", () => {
|
||||||
assert.match(markup, /1-phasig/);
|
assert.match(markup, /1-phasig/);
|
||||||
assert.match(markup, /3-phasig/);
|
assert.match(markup, /3-phasig/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects empty required numbers while keeping zero valid", () => {
|
||||||
|
assert.equal(isProjectDeviceNumberInRange("", 0), false);
|
||||||
|
assert.equal(isProjectDeviceNumberInRange(" ", 0), false);
|
||||||
|
assert.equal(isProjectDeviceNumberInRange("0", 0), true);
|
||||||
|
assert.equal(isProjectDeviceNumberInRange("0,8", 0, 1), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders blank area values as invalid and warns about duplicate names", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(ProjectDeviceModal, {
|
||||||
|
existingDevices: [{ name: "Bürofläche", displayName: "Bürofläche" }],
|
||||||
|
globalDevices: [],
|
||||||
|
initialValues: {
|
||||||
|
name: "Bürofläche",
|
||||||
|
displayName: "Bürofläche",
|
||||||
|
quantity: null,
|
||||||
|
powerPerUnit: null,
|
||||||
|
},
|
||||||
|
isSaving: false,
|
||||||
|
onClose: () => undefined,
|
||||||
|
onImportGlobal: async () => undefined,
|
||||||
|
onSave: async () => undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.match(markup, /gleichem internen Namen oder Anzeigenamen/);
|
||||||
|
assert.match(markup, /id="device-quantity"[^>]*value=""/);
|
||||||
|
assert.match(markup, /id="device-power"[^>]*value=""/);
|
||||||
|
assert.match(markup, /<button class="btn btn-primary" disabled="" type="submit">/);
|
||||||
|
assert.equal(
|
||||||
|
hasExistingProjectDeviceName(" bürofläche ", "", [
|
||||||
|
{ name: "BÜROFLÄCHE", displayName: "Fläche" },
|
||||||
|
]),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("project version history API", () => {
|
describe("project version history API", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue