forked from jappel/leistungsbilanz-ts
- Gzf-Picker-Modal mit AMEV-Tabelle 3 (Planungshilfe für elektrische Leistungsbilanzen) im Projektgerät-Modal und im globalen Geräteformular - Docker-Deploy-Tooling (docker-start.sh, run-migrations.js) für den produktiven Container - Seitenleiste ist jetzt einklappbar (Icon-only, Zustand in localStorage)
321 lines
9.7 KiB
TypeScript
321 lines
9.7 KiB
TypeScript
"use client";
|
|
|
|
import React, { FormEvent, useState } from "react";
|
|
import type {
|
|
CreateProjectDeviceInput,
|
|
GlobalDeviceDto,
|
|
ProjectDeviceDto,
|
|
} from "../types";
|
|
import { FormModal } from "./form-modal";
|
|
import {
|
|
circuitGroupCategories,
|
|
circuitGroupCategoryLabels,
|
|
type CircuitGroupCategory,
|
|
} from "../../shared/constants/circuit-group";
|
|
import { GzfPickerModal } from "./gzf-picker-modal";
|
|
|
|
interface ProjectDeviceModalProps {
|
|
globalDevices: GlobalDeviceDto[];
|
|
initialDevice?: ProjectDeviceDto;
|
|
initialValues?: Partial<CreateProjectDeviceInput>;
|
|
isSaving: boolean;
|
|
onClose: () => void;
|
|
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
|
onSave: (input: CreateProjectDeviceInput) => Promise<void>;
|
|
}
|
|
|
|
export function ProjectDeviceModal({
|
|
globalDevices,
|
|
initialDevice,
|
|
initialValues,
|
|
isSaving,
|
|
onClose,
|
|
onImportGlobal,
|
|
onSave,
|
|
}: ProjectDeviceModalProps) {
|
|
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
|
|
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
|
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
|
|
|
|
function update(key: keyof typeof values, value: string) {
|
|
setValues((current) => ({ ...current, [key]: value }));
|
|
}
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
await onSave({
|
|
name: values.name.trim(),
|
|
displayName: values.displayName.trim() || values.name.trim(),
|
|
connectionKind: optionalString(values.connectionKind),
|
|
costGroup: optionalString(values.costGroup),
|
|
category: values.category as CircuitGroupCategory,
|
|
quantity: parseDecimal(values.quantity),
|
|
powerPerUnit: parseDecimal(values.powerPerUnit),
|
|
simultaneityFactor: parseDecimal(values.simultaneityFactor),
|
|
cosPhi: optionalNumber(values.cosPhi),
|
|
remark: optionalString(values.remark),
|
|
});
|
|
}
|
|
|
|
const isValid =
|
|
values.name.trim().length > 0 &&
|
|
isNumberInRange(values.quantity, 0) &&
|
|
isNumberInRange(values.powerPerUnit, 0) &&
|
|
isNumberInRange(values.simultaneityFactor, 0, 1) &&
|
|
(!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1));
|
|
|
|
return (
|
|
<>
|
|
<FormModal
|
|
description={
|
|
initialDevice
|
|
? "Kanonische Gerätedaten bearbeiten; verknüpfte Stromkreiszeilen werden nicht automatisch überschrieben."
|
|
: "Ein Gerät manuell anlegen oder aus der globalen Bibliothek übernehmen."
|
|
}
|
|
isSaving={isSaving}
|
|
onClose={onClose}
|
|
onSubmit={handleSubmit}
|
|
submitDisabled={!isValid}
|
|
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
|
|
title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"}
|
|
>
|
|
{!initialDevice && globalDevices.length > 0 ? (
|
|
<div className="border rounded p-3 mb-4">
|
|
<label className="form-label" htmlFor="global-project-device">
|
|
Aus globaler Gerätebibliothek
|
|
</label>
|
|
<div className="input-group">
|
|
<select
|
|
className="form-select"
|
|
id="global-project-device"
|
|
onChange={(event) => setSelectedGlobalDeviceId(event.target.value)}
|
|
value={selectedGlobalDeviceId}
|
|
>
|
|
<option value="">Gerät auswählen</option>
|
|
{globalDevices.map((device) => (
|
|
<option key={device.id} value={device.id}>
|
|
{device.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
disabled={isSaving || !selectedGlobalDeviceId}
|
|
onClick={() => void onImportGlobal(selectedGlobalDeviceId)}
|
|
type="button"
|
|
>
|
|
Übernehmen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="row g-3">
|
|
<TextField
|
|
id="device-name"
|
|
label="Interner Name"
|
|
onChange={(value) => update("name", value)}
|
|
required
|
|
value={values.name}
|
|
/>
|
|
<TextField
|
|
id="device-display-name"
|
|
label="Anzeigename"
|
|
onChange={(value) => update("displayName", value)}
|
|
value={values.displayName}
|
|
/>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="device-category">
|
|
Kategorie
|
|
</label>
|
|
<select
|
|
className="form-select"
|
|
id="device-category"
|
|
onChange={(event) =>
|
|
update("category", event.target.value as CircuitGroupCategory)
|
|
}
|
|
value={values.category}
|
|
>
|
|
{circuitGroupCategories.map((category) => (
|
|
<option key={category} value={category}>
|
|
{circuitGroupCategoryLabels[category]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<TextField
|
|
id="device-connection"
|
|
label="Anschlussart"
|
|
onChange={(value) => update("connectionKind", value)}
|
|
value={values.connectionKind}
|
|
/>
|
|
<TextField
|
|
id="device-cost-group"
|
|
label="Kostengruppe"
|
|
onChange={(value) => update("costGroup", value)}
|
|
value={values.costGroup}
|
|
/>
|
|
<NumberField
|
|
id="device-quantity"
|
|
label="Anzahl"
|
|
min="0"
|
|
onChange={(value) => update("quantity", value)}
|
|
value={values.quantity}
|
|
/>
|
|
<NumberField
|
|
id="device-power"
|
|
label="Leistung je Stück [kW]"
|
|
min="0"
|
|
onChange={(value) => update("powerPerUnit", value)}
|
|
step="0.01"
|
|
value={values.powerPerUnit}
|
|
/>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="device-simultaneity">
|
|
Gleichzeitigkeitsfaktor
|
|
</label>
|
|
<div className="input-group">
|
|
<input
|
|
className="form-control"
|
|
id="device-simultaneity"
|
|
max="1"
|
|
min="0"
|
|
onChange={(event) => update("simultaneityFactor", event.target.value)}
|
|
step="0.01"
|
|
type="number"
|
|
value={values.simultaneityFactor}
|
|
/>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={() => setIsGzfPickerOpen(true)}
|
|
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
|
type="button"
|
|
>
|
|
AMEV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<NumberField
|
|
id="device-cos-phi"
|
|
label="cos Phi"
|
|
max="1"
|
|
min="0"
|
|
onChange={(value) => update("cosPhi", value)}
|
|
step="0.01"
|
|
value={values.cosPhi}
|
|
/>
|
|
<div className="col-12">
|
|
<label className="form-label" htmlFor="device-remark">
|
|
Bemerkung
|
|
</label>
|
|
<textarea
|
|
className="form-control"
|
|
id="device-remark"
|
|
onChange={(event) => update("remark", event.target.value)}
|
|
rows={3}
|
|
value={values.remark}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</FormModal>
|
|
{isGzfPickerOpen ? (
|
|
<GzfPickerModal
|
|
onClose={() => setIsGzfPickerOpen(false)}
|
|
onSelect={(value) => {
|
|
update("simultaneityFactor", String(value));
|
|
setIsGzfPickerOpen(false);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface FieldProps {
|
|
id: string;
|
|
label: string;
|
|
onChange: (value: string) => void;
|
|
required?: boolean;
|
|
value: string;
|
|
}
|
|
|
|
function TextField({ id, label, onChange, required, value }: FieldProps) {
|
|
return (
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor={id}>
|
|
{label}
|
|
</label>
|
|
<input
|
|
className="form-control"
|
|
id={id}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
required={required}
|
|
value={value}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NumberField({
|
|
id,
|
|
label,
|
|
max,
|
|
min,
|
|
onChange,
|
|
step,
|
|
value,
|
|
}: FieldProps & { max?: string; min?: string; step?: string }) {
|
|
return (
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor={id}>
|
|
{label}
|
|
</label>
|
|
<input
|
|
className="form-control"
|
|
id={id}
|
|
max={max}
|
|
min={min}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
step={step}
|
|
inputMode="decimal"
|
|
type="text"
|
|
value={value}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function toFormValues(
|
|
device?: ProjectDeviceDto,
|
|
initialValues?: Partial<CreateProjectDeviceInput>
|
|
) {
|
|
return {
|
|
name: device?.name ?? initialValues?.name ?? "",
|
|
displayName: device?.displayName ?? initialValues?.displayName ?? "",
|
|
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),
|
|
simultaneityFactor: String(device?.simultaneityFactor ?? initialValues?.simultaneityFactor ?? 1),
|
|
cosPhi: String(device?.cosPhi ?? initialValues?.cosPhi ?? 1),
|
|
remark: device?.remark ?? initialValues?.remark ?? "",
|
|
};
|
|
}
|
|
|
|
function optionalString(value: string) {
|
|
return value.trim() || undefined;
|
|
}
|
|
|
|
function optionalNumber(value: string) {
|
|
return value.trim() ? parseDecimal(value) : undefined;
|
|
}
|
|
|
|
function parseDecimal(value: string) {
|
|
return Number(value.trim().replace(",", "."));
|
|
}
|
|
|
|
function isNumberInRange(value: string, min: number, max = Number.POSITIVE_INFINITY) {
|
|
const parsed = parseDecimal(value);
|
|
return Number.isFinite(parsed) && parsed >= min && parsed <= max;
|
|
}
|