Streamline project management UI

This commit is contained in:
Julian Appel 2026-07-28 18:30:44 +02:00
parent d77cfbeb49
commit 5fa2a701dc
5 changed files with 666 additions and 378 deletions

View file

@ -0,0 +1,75 @@
"use client";
import React, { type FormEvent, type ReactNode } from "react";
interface FormModalProps {
children: ReactNode;
description?: string;
isSaving: boolean;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
submitDisabled?: boolean;
submitLabel: string;
title: string;
}
export function FormModal({
children,
description,
isSaving,
onClose,
onSubmit,
submitDisabled,
submitLabel,
title,
}: FormModalProps) {
return (
<>
<div
aria-modal="true"
className="modal fade show d-block"
role="dialog"
tabIndex={-1}
>
<div className="modal-dialog modal-lg modal-dialog-centered">
<form className="modal-content" onSubmit={onSubmit}>
<div className="modal-header">
<div>
<h2 className="modal-title fs-5">{title}</h2>
{description ? (
<p className="text-secondary small mb-0">{description}</p>
) : null}
</div>
<button
aria-label="Schließen"
className="btn-close"
disabled={isSaving}
onClick={onClose}
type="button"
/>
</div>
<div className="modal-body">{children}</div>
<div className="modal-footer">
<button
className="btn btn-outline-secondary"
disabled={isSaving}
onClick={onClose}
type="button"
>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={isSaving || submitDisabled}
type="submit"
>
{isSaving ? "Wird gespeichert …" : submitLabel}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}

View file

@ -0,0 +1,285 @@
"use client";
import React, { FormEvent, useState } from "react";
import type {
CreateProjectDeviceInput,
GlobalDeviceDto,
ProjectDeviceDto,
} from "../types";
import { FormModal } from "./form-modal";
interface ProjectDeviceModalProps {
globalDevices: GlobalDeviceDto[];
initialDevice?: ProjectDeviceDto;
isSaving: boolean;
onClose: () => void;
onImportGlobal: (globalDeviceId: string) => Promise<void>;
onSave: (input: CreateProjectDeviceInput) => Promise<void>;
}
export function ProjectDeviceModal({
globalDevices,
initialDevice,
isSaving,
onClose,
onImportGlobal,
onSave,
}: ProjectDeviceModalProps) {
const [values, setValues] = useState(() => toFormValues(initialDevice));
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
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(),
phaseType:
values.phaseType === "three_phase" ? "three_phase" : "single_phase",
connectionKind: optionalString(values.connectionKind),
costGroup: optionalString(values.costGroup),
category: optionalString(values.category),
quantity: Number(values.quantity),
powerPerUnit: Number(values.powerPerUnit),
simultaneityFactor: Number(values.simultaneityFactor),
cosPhi: optionalNumber(values.cosPhi),
remark: optionalString(values.remark),
voltageV: optionalNumber(values.voltageV),
});
}
const isValid =
values.name.trim().length > 0 &&
Number(values.quantity) >= 0 &&
Number(values.powerPerUnit) >= 0 &&
Number(values.simultaneityFactor) >= 0 &&
Number(values.simultaneityFactor) <= 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}
/>
<TextField
id="device-category"
label="Kategorie"
onChange={(value) => update("category", value)}
value={values.category}
/>
<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}
/>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="device-phase-type">
Phasenart
</label>
<select
className="form-select"
id="device-phase-type"
onChange={(event) => update("phaseType", event.target.value)}
value={values.phaseType}
>
<option value="single_phase">1-phasig</option>
<option value="three_phase">3-phasig</option>
</select>
</div>
<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}
/>
<NumberField
id="device-simultaneity"
label="Gleichzeitigkeitsfaktor"
max="1"
min="0"
onChange={(value) => update("simultaneityFactor", value)}
step="0.01"
value={values.simultaneityFactor}
/>
<NumberField
id="device-cos-phi"
label="cos Phi"
max="1"
min="0"
onChange={(value) => update("cosPhi", value)}
step="0.01"
value={values.cosPhi}
/>
<NumberField
id="device-voltage"
label="Spannung [V]"
min="1"
onChange={(value) => update("voltageV", value)}
value={values.voltageV}
/>
<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>
);
}
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}
type="number"
value={value}
/>
</div>
);
}
function toFormValues(device?: ProjectDeviceDto) {
return {
name: device?.name ?? "",
displayName: device?.displayName ?? "",
phaseType: device?.phaseType ?? "single_phase",
connectionKind: device?.connectionKind ?? "",
costGroup: device?.costGroup ?? "",
category: device?.category ?? "",
quantity: String(device?.quantity ?? 1),
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
cosPhi: String(device?.cosPhi ?? 1),
remark: device?.remark ?? "",
voltageV: String(device?.voltageV ?? ""),
};
}
function optionalString(value: string) {
return value.trim() || undefined;
}
function optionalNumber(value: string) {
return value.trim() ? Number(value) : undefined;
}