Streamline project management UI

This commit is contained in:
2026-07-28 18:30:44 +02:00
parent d77cfbeb49
commit 5fa2a701dc
5 changed files with 666 additions and 378 deletions
+7
View File
@@ -219,6 +219,13 @@ verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
ausführbar; fehlende Metadaten werden dabei als `null` behandelt. 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 `distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
+268 -378
View File
@@ -44,6 +44,8 @@ import {
ProjectSettingsModal, ProjectSettingsModal,
type ProjectSettingsInput, type ProjectSettingsInput,
} from "../../../frontend/components/project-settings-modal"; } from "../../../frontend/components/project-settings-modal";
import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = { const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name", name: "Technischer Name",
@@ -59,28 +61,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
remark: "Bemerkung", 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() { export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>(); const params = useParams<{ projectId: string }>();
const [projectId, setProjectId] = useState(""); const [projectId, setProjectId] = useState("");
@@ -97,20 +77,14 @@ export default function ProjectDetailPage() {
const [roomName, setRoomName] = useState(""); const [roomName, setRoomName] = useState("");
const [roomFloorId, setRoomFloorId] = useState(""); const [roomFloorId, setRoomFloorId] = useState("");
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [projectDeviceForm, setProjectDeviceForm] = useState<Record<string, string>>({ const [structureModal, setStructureModal] = useState<
name: "", "board" | "floor" | "room" | null
displayName: "", >(null);
phaseType: "single_phase", const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
connectionKind: "", useState(false);
costGroup: "", const [editingProjectDevice, setEditingProjectDevice] =
category: "", useState<ProjectDeviceDto | null>(null);
quantity: "1", const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
powerPerUnit: "0.1",
simultaneityFactor: "1",
cosPhi: "1",
remark: "",
});
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null); const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]); const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
@@ -162,6 +136,21 @@ export default function ProjectDetailPage() {
const floorCount = useMemo(() => floors.length, [floors.length]); const floorCount = useMemo(() => floors.length, [floors.length]);
const roomCount = useMemo(() => rooms.length, [rooms.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]);
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.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 floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
const circuitListByBoardId = useMemo( const circuitListByBoardId = useMemo(
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
@@ -194,6 +183,7 @@ export default function ProjectDetailPage() {
setCircuitLists(await listCircuitLists(projectId)); setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setBoardName(""); setBoardName("");
setStructureModal(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden."); setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
} finally { } finally {
@@ -217,6 +207,7 @@ export default function ProjectDetailPage() {
setFloors((current) => [...current, result.floor]); setFloors((current) => [...current, result.floor]);
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setFloorName(""); setFloorName("");
setStructureModal(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden."); setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden.");
} finally { } finally {
@@ -251,6 +242,7 @@ export default function ProjectDetailPage() {
setRoomNumber(""); setRoomNumber("");
setRoomName(""); setRoomName("");
setRoomFloorId(""); setRoomFloorId("");
setStructureModal(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden."); setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden.");
} finally { } finally {
@@ -279,26 +271,13 @@ export default function ProjectDetailPage() {
} }
} }
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) { async function handleCreateProjectDevice(
event.preventDefault(); payload: CreateProjectDeviceInput
if (!projectId || !project || !projectDeviceForm.name.trim()) { ) {
if (!projectId || !project || !payload.name.trim()) {
return; 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); setIsSaving(true);
setError(null); setError(null);
try { try {
@@ -309,19 +288,7 @@ export default function ProjectDetailPage() {
); );
setProjectDevices((current) => [...current, result.projectDevice]); setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setProjectDeviceForm({ setIsProjectDeviceModalOpen(false);
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 ?? "",
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden."); setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
} finally { } finally {
@@ -350,30 +317,16 @@ export default function ProjectDetailPage() {
} }
} }
async function handleQuickUpdateProjectDevice( async function handleUpdateProjectDevice(
device: ProjectDeviceDto, device: ProjectDeviceDto,
key: "name" | "displayName" | "category", payload: CreateProjectDeviceInput
value: string
) { ) {
if (!projectId || !project) { if (!projectId || !project) {
return; return;
} }
const payload: CreateProjectDeviceInput = { setIsSaving(true);
name: key === "name" ? value : device.name, setError(null);
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,
};
try { try {
const result = await updateProjectDevice( const result = await updateProjectDevice(
projectId, projectId,
@@ -387,9 +340,12 @@ export default function ProjectDetailPage() {
) )
); );
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setEditingProjectDevice(null);
await openProjectDeviceSyncPreview(result.projectDevice.id); await openProjectDeviceSyncPreview(result.projectDevice.id);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden."); 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() { async function handleCopyGlobalToProject(globalDeviceId: string) {
if (!projectId || !project || !selectedGlobalDeviceId) { if (!projectId || !project || !globalDeviceId) {
return; return;
} }
@@ -498,11 +454,12 @@ export default function ProjectDetailPage() {
try { try {
const result = await copyGlobalDeviceToProject( const result = await copyGlobalDeviceToProject(
projectId, projectId,
selectedGlobalDeviceId, globalDeviceId,
project.currentRevision project.currentRevision
); );
setProjectDevices((current) => [...current, result.projectDevice]); setProjectDevices((current) => [...current, result.projectDevice]);
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setIsProjectDeviceModalOpen(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden."); setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
} finally { } finally {
@@ -567,30 +524,20 @@ export default function ProjectDetailPage() {
</section> </section>
) : null} ) : null}
<section className="col-12 col-lg-4"> <section className="col-12">
<div className="card shadow-sm"> <div className="card shadow-sm">
<div className="card-header">Neue Verteilung</div> <div className="card-header d-flex justify-content-between align-items-center">
<div className="card-body"> <div>
<form className="vstack gap-3" onSubmit={handleCreateBoard}> <span>Verteilungen</span>
<input <span className="badge text-bg-secondary ms-2">{boardCount}</span>
className="form-control" </div>
placeholder="z. B. UV-01" <button
value={boardName} className="btn btn-sm btn-primary"
onChange={(event) => setBoardName(event.target.value)} onClick={() => setStructureModal("board")}
/> type="button"
<button className="btn btn-primary" type="submit" disabled={isSaving}> >
Verteilung erstellen Verteilung hinzufügen
</button> </button>
</form>
</div>
</div>
</section>
<section className="col-12 col-lg-8">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between">
<span>Alle Verteilungen</span>
<span className="badge text-bg-secondary">{boardCount}</span>
</div> </div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-hover align-middle mb-0"> <table className="table table-hover align-middle mb-0">
@@ -638,24 +585,20 @@ export default function ProjectDetailPage() {
</div> </div>
</section> </section>
<section className="col-12 col-xl-4"> <section className="col-12 col-lg-5">
<div className="card shadow-sm h-100"> <div className="card shadow-sm h-100">
<div className="card-header d-flex justify-content-between"> <div className="card-header d-flex justify-content-between align-items-center">
<span>Etagen</span> <div>
<span className="badge text-bg-secondary">{floorCount}</span> <span>Etagen</span>
</div> <span className="badge text-bg-secondary ms-2">{floorCount}</span>
<div className="card-body border-bottom"> </div>
<form className="vstack gap-2" onSubmit={handleCreateFloor}> <button
<input className="btn btn-sm btn-outline-primary"
className="form-control" onClick={() => setStructureModal("floor")}
placeholder="z. B. EG" type="button"
value={floorName} >
onChange={(event) => setFloorName(event.target.value)} Hinzufügen
/> </button>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Etage hinzufügen
</button>
</form>
</div> </div>
<ul className="list-group list-group-flush"> <ul className="list-group list-group-flush">
{floors.map((floor) => ( {floors.map((floor) => (
@@ -670,50 +613,20 @@ export default function ProjectDetailPage() {
</div> </div>
</section> </section>
<section className="col-12 col-xl-8"> <section className="col-12 col-lg-7">
<div className="card shadow-sm"> <div className="card shadow-sm">
<div className="card-header d-flex justify-content-between"> <div className="card-header d-flex justify-content-between align-items-center">
<span>Räume</span> <div>
<span className="badge text-bg-secondary">{roomCount}</span> <span>Räume</span>
</div> <span className="badge text-bg-secondary ms-2">{roomCount}</span>
<div className="card-body border-bottom"> </div>
<form className="row g-2" onSubmit={handleCreateRoom}> <button
<div className="col-12 col-lg-3"> className="btn btn-sm btn-outline-primary"
<input onClick={() => setStructureModal("room")}
className="form-control" type="button"
placeholder="Raumnummer" >
value={roomNumber} Hinzufügen
onChange={(event) => setRoomNumber(event.target.value)} </button>
/>
</div>
<div className="col-12 col-lg-4">
<input
className="form-control"
placeholder="Raumname"
value={roomName}
onChange={(event) => setRoomName(event.target.value)}
/>
</div>
<div className="col-12 col-lg-3">
<select
className="form-select"
value={roomFloorId}
onChange={(event) => setRoomFloorId(event.target.value)}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
<div className="col-12 col-lg-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Raum anlegen
</button>
</div>
</form>
</div> </div>
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-hover align-middle mb-0"> <table className="table table-hover align-middle mb-0">
@@ -747,167 +660,26 @@ export default function ProjectDetailPage() {
</div> </div>
<section className="card shadow-sm mt-4"> <section className="card shadow-sm mt-4">
<div className="card-header d-flex justify-content-between"> <div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<span>Projektgeräte / Verbraucher</span> <div>
<span className="badge text-bg-secondary">{projectDeviceCount}</span> <span>Projektgeräte</span>
</div> <span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
<div className="card-body border-bottom"> </div>
<form className="row g-2" onSubmit={handleCreateProjectDevice}> <div className="d-flex gap-2">
<div className="col-12 col-md-2"> <input
<input aria-label="Projektgeräte durchsuchen"
className="form-control" className="form-control form-control-sm"
placeholder="Interner Name" onChange={(event) => setProjectDeviceQuery(event.target.value)}
value={projectDeviceForm.name} placeholder="Geräte durchsuchen"
onChange={(event) => type="search"
setProjectDeviceForm((current) => ({ ...current, name: event.target.value })) value={projectDeviceQuery}
} />
/>
</div>
<div className="col-12 col-md-2">
<input
className="form-control"
placeholder="Anzeigename"
value={projectDeviceForm.displayName}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, displayName: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Kategorie"
value={projectDeviceForm.category}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, category: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Anschlussart"
value={projectDeviceForm.connectionKind}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, connectionKind: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
placeholder="Kostengruppe"
value={projectDeviceForm.costGroup}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, costGroup: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
min="0"
title="Anzahl"
value={projectDeviceForm.quantity}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, quantity: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
title="Leistung je Stück [kW]"
value={projectDeviceForm.powerPerUnit}
onChange={(event) =>
setProjectDeviceForm((current) => ({
...current,
powerPerUnit: event.target.value,
}))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
max="1"
title="Gleichzeitigkeitsfaktor"
value={projectDeviceForm.simultaneityFactor}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, simultaneityFactor: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<input
className="form-control"
type="number"
step="0.01"
min="0"
max="1"
title="cos Phi"
value={projectDeviceForm.cosPhi}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, cosPhi: event.target.value }))
}
/>
</div>
<div className="col-6 col-md-1">
<select
className="form-select"
title="Phasenart"
value={projectDeviceForm.phaseType}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, phaseType: event.target.value }))
}
>
<option value="single_phase">1-ph</option>
<option value="three_phase">3-ph</option>
</select>
</div>
<div className="col-12 col-md-10">
<input
className="form-control"
placeholder="Bemerkung"
value={projectDeviceForm.remark}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, remark: event.target.value }))
}
/>
</div>
<div className="col-12 col-md-2">
<button className="btn btn-primary w-100" type="submit" disabled={isSaving}>
Gerät anlegen
</button>
</div>
</form>
<div className="input-group mt-3">
<select
className="form-select"
value={selectedGlobalDeviceId}
onChange={(event) => setSelectedGlobalDeviceId(event.target.value)}
>
<option value="">Globales Gerät auswählen</option>
{globalDevices.map((device) => (
<option key={device.id} value={device.id}>
{device.displayName}
</option>
))}
</select>
<button <button
className="btn btn-outline-primary" className="btn btn-sm btn-primary text-nowrap"
onClick={() => setIsProjectDeviceModalOpen(true)}
type="button" type="button"
onClick={handleCopyGlobalToProject}
disabled={!selectedGlobalDeviceId || isSaving}
> >
Nach Projekt kopieren Gerät hinzufügen
</button> </button>
</div> </div>
</div> </div>
@@ -915,64 +687,51 @@ export default function ProjectDetailPage() {
<table className="table table-sm table-striped align-middle mb-0"> <table className="table table-sm table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th>Interner Name</th> <th>Gerät</th>
<th>Anzeigename</th> <th>Anschluss</th>
<th>Phasenart</th> <th>Leistung</th>
<th>Kategorie</th>
<th>Kostengruppe</th> <th>Kostengruppe</th>
<th>Anzahl</th> <th className="text-end">Aktionen</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Gesamtleistung [kW]</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{projectDevices.map((device) => ( {visibleProjectDevices.map((device) => (
<tr key={device.id}> <tr key={device.id}>
<td> <td>
<input <strong>{device.displayName}</strong>
className="form-control form-control-sm" <div className="small text-secondary">
defaultValue={device.name} {device.name}
onBlur={(event) => {device.category ? ` · ${device.category}` : ""}
event.target.value !== device.name </div>
? handleQuickUpdateProjectDevice(device, "name", event.target.value)
: undefined
}
/>
</td> </td>
<td> <td>
<input {device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
className="form-control form-control-sm" {device.connectionKind ? (
defaultValue={device.displayName} <div className="small text-secondary">
onBlur={(event) => {device.connectionKind}
event.target.value !== device.displayName </div>
? handleQuickUpdateProjectDevice(device, "displayName", event.target.value) ) : null}
: undefined
}
/>
</td> </td>
<td>{device.phaseType === "three_phase" ? "3-ph" : "1-ph"}</td>
<td> <td>
<input {device.totalPower} kW
className="form-control form-control-sm" <div className="small text-secondary">
defaultValue={device.category ?? ""} {device.quantity} × {device.powerPerUnit} kW · GZF{" "}
onBlur={(event) => {device.simultaneityFactor}
event.target.value !== (device.category ?? "") </div>
? handleQuickUpdateProjectDevice(device, "category", event.target.value)
: undefined
}
/>
</td> </td>
<td>{device.costGroup ?? "-"}</td> <td>{device.costGroup ?? "-"}</td>
<td>{device.quantity}</td> <td className="text-end">
<td>{device.powerPerUnit}</td> <div className="d-inline-flex flex-wrap justify-content-end gap-1">
<td>{device.simultaneityFactor}</td>
<td>{device.totalPower}</td>
<td>
<div className="btn-group btn-group-sm">
<button <button
className="btn btn-outline-primary" className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => setEditingProjectDevice(device)}
disabled={isSaving}
>
Bearbeiten
</button>
<button
className="btn btn-sm btn-outline-primary"
type="button" type="button"
onClick={() => void openProjectDeviceSyncPreview(device.id)} onClick={() => void openProjectDeviceSyncPreview(device.id)}
disabled={isSaving} disabled={isSaving}
@@ -980,7 +739,7 @@ export default function ProjectDetailPage() {
Verknüpfungen Verknüpfungen
</button> </button>
<button <button
className="btn btn-outline-secondary" className="btn btn-sm btn-outline-secondary"
type="button" type="button"
onClick={() => handleCopyProjectToGlobal(device.id)} onClick={() => handleCopyProjectToGlobal(device.id)}
disabled={isSaving} disabled={isSaving}
@@ -988,7 +747,7 @@ export default function ProjectDetailPage() {
Nach global kopieren Nach global kopieren
</button> </button>
<button <button
className="btn btn-outline-danger" className="btn btn-sm btn-outline-danger"
type="button" type="button"
onClick={() => handleDeleteProjectDevice(device.id)} onClick={() => handleDeleteProjectDevice(device.id)}
disabled={isSaving} disabled={isSaving}
@@ -999,10 +758,12 @@ export default function ProjectDetailPage() {
</td> </td>
</tr> </tr>
))} ))}
{!projectDevices.length ? ( {!visibleProjectDevices.length ? (
<tr> <tr>
<td colSpan={10} className="text-center text-secondary py-4"> <td colSpan={5} className="text-center text-secondary py-4">
Noch keine Projektgeräte vorhanden. {projectDevices.length
? "Keine Projektgeräte entsprechen der Suche."
: "Noch keine Projektgeräte vorhanden."}
</td> </td>
</tr> </tr>
) : null} ) : null}
@@ -1121,6 +882,135 @@ export default function ProjectDetailPage() {
) : null} ) : null}
</section> </section>
{structureModal === "board" ? (
<FormModal
description="Eine Stromkreisliste mit den vier Standardbereichen wird automatisch angelegt."
isSaving={isSaving}
onClose={() => setStructureModal(null)}
onSubmit={handleCreateBoard}
submitDisabled={!boardName.trim()}
submitLabel="Verteilung erstellen"
title="Verteilung hinzufügen"
>
<label className="form-label" htmlFor="distribution-board-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="distribution-board-name"
onChange={(event) => setBoardName(event.target.value)}
placeholder="z. B. UV-01"
required
value={boardName}
/>
</FormModal>
) : null}
{structureModal === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => setStructureModal(null)}
onSubmit={handleCreateFloor}
submitDisabled={!floorName.trim()}
submitLabel="Etage hinzufügen"
title="Etage hinzufügen"
>
<label className="form-label" htmlFor="floor-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="floor-name"
onChange={(event) => setFloorName(event.target.value)}
placeholder="z. B. EG"
required
value={floorName}
/>
</FormModal>
) : null}
{structureModal === "room" ? (
<FormModal
isSaving={isSaving}
onClose={() => setStructureModal(null)}
onSubmit={handleCreateRoom}
submitDisabled={!roomNumber.trim() || !roomName.trim()}
submitLabel="Raum anlegen"
title="Raum hinzufügen"
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="room-number">
Raumnummer
</label>
<input
autoFocus
className="form-control"
id="room-number"
onChange={(event) => setRoomNumber(event.target.value)}
required
value={roomNumber}
/>
</div>
<div className="col-12 col-md-8">
<label className="form-label" htmlFor="room-name">
Raumname
</label>
<input
className="form-control"
id="room-name"
onChange={(event) => setRoomName(event.target.value)}
required
value={roomName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="room-floor">
Etage
</label>
<select
className="form-select"
id="room-floor"
onChange={(event) => setRoomFloorId(event.target.value)}
value={roomFloorId}
>
<option value="">Ohne Etage</option>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{isProjectDeviceModalOpen ? (
<ProjectDeviceModal
globalDevices={globalDevices}
isSaving={isSaving}
onClose={() => setIsProjectDeviceModalOpen(false)}
onImportGlobal={handleCopyGlobalToProject}
onSave={handleCreateProjectDevice}
/>
) : null}
{editingProjectDevice ? (
<ProjectDeviceModal
globalDevices={globalDevices}
initialDevice={editingProjectDevice}
isSaving={isSaving}
onClose={() => setEditingProjectDevice(null)}
onImportGlobal={handleCopyGlobalToProject}
onSave={(input) =>
handleUpdateProjectDevice(editingProjectDevice, input)
}
/>
) : null}
{project && isProjectSettingsOpen ? ( {project && isProjectSettingsOpen ? (
<ProjectSettingsModal <ProjectSettingsModal
isSaving={isSaving} isSaving={isSaving}
+75
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" />
</>
);
}
@@ -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;
}
+31
View File
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
import { createElement } from "react"; import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js"; import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import { ProjectDeviceModal } from "../src/frontend/components/project-device-modal.js";
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js"; import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
import { import {
createNamedProjectSnapshot, createNamedProjectSnapshot,
@@ -98,6 +99,36 @@ describe("project version history presentation", () => {
}); });
}); });
describe("project device modal presentation", () => {
it("renders explicit German labels for every editable device field", () => {
const markup = renderToStaticMarkup(
createElement(ProjectDeviceModal, {
globalDevices: [],
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
})
);
for (const label of [
"Projektgerät hinzufügen",
"Interner Name",
"Anzeigename",
"Kategorie",
"Anschlussart",
"Kostengruppe",
"Phasenart",
"Anzahl",
"Leistung je Stück [kW]",
"Gleichzeitigkeitsfaktor",
"Spannung [V]",
"Bemerkung",
]) {
assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]")));
}
});
});
describe("project version history API", () => { describe("project version history API", () => {
it("uses the revision-safe timeline and snapshot routes", async () => { it("uses the revision-safe timeline and snapshot routes", async () => {
const requests: Array<{ const requests: Array<{