leistungsbilanz-ts/src/frontend/components/project-device-modal.tsx
Grovy311 9bd22dfcb7 Add AMEV Gleichzeitigkeitsfaktor-Auswähler und einklappbare Seitenleiste
- 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)
2026-08-03 17:07:14 +02:00

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