forked from jappel/leistungsbilanz-ts
Streamline project management UI
This commit is contained in:
parent
d77cfbeb49
commit
5fa2a701dc
5 changed files with 666 additions and 378 deletions
75
src/frontend/components/form-modal.tsx
Normal file
75
src/frontend/components/form-modal.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
285
src/frontend/components/project-device-modal.tsx
Normal file
285
src/frontend/components/project-device-modal.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue