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
|
|
@ -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"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue