diff --git a/docs/current-architecture.md b/docs/current-architecture.md index ed3812e..45e44a7 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -219,6 +219,13 @@ verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und ausführbar; fehlende Metadaten werden dabei als `null` behandelt. +Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte +Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals +statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare, +fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen +Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. +Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, +damit Änderungen weiterhin niemals still synchronisiert werden. `distribution-board.insert` versioniert die Anlage einer Verteilung als einen vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index f36c0b6..596d596 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -44,6 +44,8 @@ import { ProjectSettingsModal, type ProjectSettingsInput, } from "../../../frontend/components/project-settings-modal"; +import { FormModal } from "../../../frontend/components/form-modal"; +import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; const projectDeviceSyncFieldLabels: Record = { name: "Technischer Name", @@ -59,28 +61,6 @@ const projectDeviceSyncFieldLabels: Record = { remark: "Bemerkung", }; -const emptyProjectDevice: CreateProjectDeviceInput = { - name: "", - displayName: "", - phaseType: "single_phase", - connectionKind: "", - costGroup: "", - category: "", - quantity: 1, - powerPerUnit: 0.1, - simultaneityFactor: 1, - cosPhi: 1, - remark: "", -}; - -function toOptionalNumber(value: string) { - const trimmed = value.trim(); - if (!trimmed) { - return undefined; - } - return Number(trimmed); -} - export default function ProjectDetailPage() { const params = useParams<{ projectId: string }>(); const [projectId, setProjectId] = useState(""); @@ -97,20 +77,14 @@ export default function ProjectDetailPage() { const [roomName, setRoomName] = useState(""); const [roomFloorId, setRoomFloorId] = useState(""); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); - const [projectDeviceForm, setProjectDeviceForm] = useState>({ - name: "", - displayName: "", - phaseType: "single_phase", - connectionKind: "", - costGroup: "", - category: "", - quantity: "1", - powerPerUnit: "0.1", - simultaneityFactor: "1", - cosPhi: "1", - remark: "", - }); - const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState(""); + const [structureModal, setStructureModal] = useState< + "board" | "floor" | "room" | null + >(null); + const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = + useState(false); + const [editingProjectDevice, setEditingProjectDevice] = + useState(null); + const [projectDeviceQuery, setProjectDeviceQuery] = useState(""); const [syncPreview, setSyncPreview] = useState(null); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState([]); const [selectedSyncFields, setSelectedSyncFields] = useState([]); @@ -162,6 +136,21 @@ export default function ProjectDetailPage() { const floorCount = useMemo(() => floors.length, [floors.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); + const visibleProjectDevices = useMemo(() => { + const query = projectDeviceQuery.trim().toLocaleLowerCase("de"); + if (!query) { + return projectDevices; + } + return projectDevices.filter((device) => + [ + device.name, + device.displayName, + device.category, + device.connectionKind, + device.costGroup, + ].some((value) => value?.toLocaleLowerCase("de").includes(query)) + ); + }, [projectDeviceQuery, projectDevices]); const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]); const circuitListByBoardId = useMemo( () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), @@ -194,6 +183,7 @@ export default function ProjectDetailPage() { setCircuitLists(await listCircuitLists(projectId)); applyProjectRevision(result.history.currentRevision); setBoardName(""); + setStructureModal(null); } catch (err) { setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden."); } finally { @@ -217,6 +207,7 @@ export default function ProjectDetailPage() { setFloors((current) => [...current, result.floor]); applyProjectRevision(result.history.currentRevision); setFloorName(""); + setStructureModal(null); } catch (err) { setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); } finally { @@ -251,6 +242,7 @@ export default function ProjectDetailPage() { setRoomNumber(""); setRoomName(""); setRoomFloorId(""); + setStructureModal(null); } catch (err) { setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden."); } finally { @@ -279,26 +271,13 @@ export default function ProjectDetailPage() { } } - async function handleCreateProjectDevice(event: FormEvent) { - event.preventDefault(); - if (!projectId || !project || !projectDeviceForm.name.trim()) { + async function handleCreateProjectDevice( + payload: CreateProjectDeviceInput + ) { + if (!projectId || !project || !payload.name.trim()) { return; } - const payload: CreateProjectDeviceInput = { - name: projectDeviceForm.name.trim(), - displayName: projectDeviceForm.displayName.trim() || projectDeviceForm.name.trim(), - phaseType: projectDeviceForm.phaseType === "three_phase" ? "three_phase" : "single_phase", - connectionKind: projectDeviceForm.connectionKind.trim() || undefined, - costGroup: projectDeviceForm.costGroup.trim() || undefined, - category: projectDeviceForm.category.trim() || undefined, - quantity: Number(projectDeviceForm.quantity), - powerPerUnit: Number(projectDeviceForm.powerPerUnit), - simultaneityFactor: Number(projectDeviceForm.simultaneityFactor), - cosPhi: toOptionalNumber(projectDeviceForm.cosPhi), - remark: projectDeviceForm.remark.trim() || undefined, - }; - setIsSaving(true); setError(null); try { @@ -309,19 +288,7 @@ export default function ProjectDetailPage() { ); setProjectDevices((current) => [...current, result.projectDevice]); applyProjectRevision(result.history.currentRevision); - setProjectDeviceForm({ - name: emptyProjectDevice.name, - displayName: emptyProjectDevice.displayName, - phaseType: emptyProjectDevice.phaseType, - connectionKind: emptyProjectDevice.connectionKind ?? "", - costGroup: emptyProjectDevice.costGroup ?? "", - category: emptyProjectDevice.category ?? "", - quantity: String(emptyProjectDevice.quantity), - powerPerUnit: String(emptyProjectDevice.powerPerUnit), - simultaneityFactor: String(emptyProjectDevice.simultaneityFactor), - cosPhi: String(emptyProjectDevice.cosPhi ?? ""), - remark: emptyProjectDevice.remark ?? "", - }); + setIsProjectDeviceModalOpen(false); } catch (err) { setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden."); } finally { @@ -350,30 +317,16 @@ export default function ProjectDetailPage() { } } - async function handleQuickUpdateProjectDevice( + async function handleUpdateProjectDevice( device: ProjectDeviceDto, - key: "name" | "displayName" | "category", - value: string + payload: CreateProjectDeviceInput ) { if (!projectId || !project) { return; } - const payload: CreateProjectDeviceInput = { - name: key === "name" ? value : device.name, - displayName: key === "displayName" ? value : device.displayName, - phaseType: device.phaseType, - connectionKind: device.connectionKind ?? undefined, - costGroup: device.costGroup ?? undefined, - category: key === "category" ? value : device.category ?? undefined, - quantity: device.quantity, - powerPerUnit: device.powerPerUnit, - simultaneityFactor: device.simultaneityFactor, - cosPhi: device.cosPhi ?? undefined, - remark: device.remark ?? undefined, - voltageV: device.voltageV ?? undefined, - }; - + setIsSaving(true); + setError(null); try { const result = await updateProjectDevice( projectId, @@ -387,9 +340,12 @@ export default function ProjectDetailPage() { ) ); applyProjectRevision(result.history.currentRevision); + setEditingProjectDevice(null); await openProjectDeviceSyncPreview(result.projectDevice.id); } catch (err) { setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden."); + } finally { + setIsSaving(false); } } @@ -488,8 +444,8 @@ export default function ProjectDetailPage() { } } - async function handleCopyGlobalToProject() { - if (!projectId || !project || !selectedGlobalDeviceId) { + async function handleCopyGlobalToProject(globalDeviceId: string) { + if (!projectId || !project || !globalDeviceId) { return; } @@ -498,11 +454,12 @@ export default function ProjectDetailPage() { try { const result = await copyGlobalDeviceToProject( projectId, - selectedGlobalDeviceId, + globalDeviceId, project.currentRevision ); setProjectDevices((current) => [...current, result.projectDevice]); applyProjectRevision(result.history.currentRevision); + setIsProjectDeviceModalOpen(false); } catch (err) { setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden."); } finally { @@ -567,30 +524,20 @@ export default function ProjectDetailPage() { ) : null} -
+
-
Neue Verteilung
-
-
- setBoardName(event.target.value)} - /> - -
-
-
-
- -
-
-
- Alle Verteilungen - {boardCount} +
+
+ Verteilungen + {boardCount} +
+
@@ -638,24 +585,20 @@ export default function ProjectDetailPage() { -
+
-
- Etagen - {floorCount} -
-
-
- setFloorName(event.target.value)} - /> - - +
+
+ Etagen + {floorCount} +
+
    {floors.map((floor) => ( @@ -670,50 +613,20 @@ export default function ProjectDetailPage() {
-
+
-
- Räume - {roomCount} -
-
-
-
- setRoomNumber(event.target.value)} - /> -
-
- setRoomName(event.target.value)} - /> -
-
- -
-
- -
- +
+
+ Räume + {roomCount} +
+
@@ -747,167 +660,26 @@ export default function ProjectDetailPage() {
-
- Projektgeräte / Verbraucher - {projectDeviceCount} -
-
-
-
- - setProjectDeviceForm((current) => ({ ...current, name: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, displayName: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, category: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, connectionKind: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, costGroup: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ - ...current, - powerPerUnit: event.target.value, - })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, simultaneityFactor: event.target.value })) - } - /> -
-
- - setProjectDeviceForm((current) => ({ ...current, cosPhi: event.target.value })) - } - /> -
-
- -
-
- - setProjectDeviceForm((current) => ({ ...current, remark: event.target.value })) - } - /> -
-
- -
- -
- +
+
+ Projektgeräte + {projectDeviceCount} +
+
+ setProjectDeviceQuery(event.target.value)} + placeholder="Geräte durchsuchen" + type="search" + value={projectDeviceQuery} + />
@@ -915,64 +687,51 @@ export default function ProjectDetailPage() {
- - - - + + + - - - - - + - {projectDevices.map((device) => ( + {visibleProjectDevices.map((device) => ( - - - - - - ))} - {!projectDevices.length ? ( + {!visibleProjectDevices.length ? ( - ) : null} @@ -1121,6 +882,135 @@ export default function ProjectDetailPage() { ) : null} + {structureModal === "board" ? ( + setStructureModal(null)} + onSubmit={handleCreateBoard} + submitDisabled={!boardName.trim()} + submitLabel="Verteilung erstellen" + title="Verteilung hinzufügen" + > + + setBoardName(event.target.value)} + placeholder="z. B. UV-01" + required + value={boardName} + /> + + ) : null} + + {structureModal === "floor" ? ( + setStructureModal(null)} + onSubmit={handleCreateFloor} + submitDisabled={!floorName.trim()} + submitLabel="Etage hinzufügen" + title="Etage hinzufügen" + > + + setFloorName(event.target.value)} + placeholder="z. B. EG" + required + value={floorName} + /> + + ) : null} + + {structureModal === "room" ? ( + setStructureModal(null)} + onSubmit={handleCreateRoom} + submitDisabled={!roomNumber.trim() || !roomName.trim()} + submitLabel="Raum anlegen" + title="Raum hinzufügen" + > +
+
+ + setRoomNumber(event.target.value)} + required + value={roomNumber} + /> +
+
+ + setRoomName(event.target.value)} + required + value={roomName} + /> +
+
+ + +
+
+
+ ) : null} + + {isProjectDeviceModalOpen ? ( + setIsProjectDeviceModalOpen(false)} + onImportGlobal={handleCopyGlobalToProject} + onSave={handleCreateProjectDevice} + /> + ) : null} + + {editingProjectDevice ? ( + setEditingProjectDevice(null)} + onImportGlobal={handleCopyGlobalToProject} + onSave={(input) => + handleUpdateProjectDevice(editingProjectDevice, input) + } + /> + ) : null} + {project && isProjectSettingsOpen ? ( void; + onSubmit: (event: FormEvent) => void; + submitDisabled?: boolean; + submitLabel: string; + title: string; +} + +export function FormModal({ + children, + description, + isSaving, + onClose, + onSubmit, + submitDisabled, + submitLabel, + title, +}: FormModalProps) { + return ( + <> +
+
+
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+
{children}
+
+ + +
+ +
+
+
+ + ); +} diff --git a/src/frontend/components/project-device-modal.tsx b/src/frontend/components/project-device-modal.tsx new file mode 100644 index 0000000..17035b4 --- /dev/null +++ b/src/frontend/components/project-device-modal.tsx @@ -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; + onSave: (input: CreateProjectDeviceInput) => Promise; +} + +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) { + 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 ( + + {!initialDevice && globalDevices.length > 0 ? ( +
+ +
+ + +
+
+ ) : null} + +
+ update("name", value)} + required + value={values.name} + /> + update("displayName", value)} + value={values.displayName} + /> + update("category", value)} + value={values.category} + /> + update("connectionKind", value)} + value={values.connectionKind} + /> + update("costGroup", value)} + value={values.costGroup} + /> +
+ + +
+ update("quantity", value)} + value={values.quantity} + /> + update("powerPerUnit", value)} + step="0.01" + value={values.powerPerUnit} + /> + update("simultaneityFactor", value)} + step="0.01" + value={values.simultaneityFactor} + /> + update("cosPhi", value)} + step="0.01" + value={values.cosPhi} + /> + update("voltageV", value)} + value={values.voltageV} + /> +
+ +
Interner NameAnzeigenamePhasenartKategorieGerätAnschlussLeistung KostengruppeAnzahlLeistung je Stück [kW]GZFGesamtleistung [kW]AktionenAktionen
- - event.target.value !== device.name - ? handleQuickUpdateProjectDevice(device, "name", event.target.value) - : undefined - } - /> + {device.displayName} +
+ {device.name} + {device.category ? ` · ${device.category}` : ""} +
- - event.target.value !== device.displayName - ? handleQuickUpdateProjectDevice(device, "displayName", event.target.value) - : undefined - } - /> + {device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"} + {device.connectionKind ? ( +
+ {device.connectionKind} +
+ ) : null}
{device.phaseType === "three_phase" ? "3-ph" : "1-ph"} - - event.target.value !== (device.category ?? "") - ? handleQuickUpdateProjectDevice(device, "category", event.target.value) - : undefined - } - /> + {device.totalPower} kW +
+ {device.quantity} × {device.powerPerUnit} kW · GZF{" "} + {device.simultaneityFactor} +
{device.costGroup ?? "-"}{device.quantity}{device.powerPerUnit}{device.simultaneityFactor}{device.totalPower} -
+
+
+
- Noch keine Projektgeräte vorhanden. + + {projectDevices.length + ? "Keine Projektgeräte entsprechen der Suche." + : "Noch keine Projektgeräte vorhanden."}