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

@ -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"))
)
);
}