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.
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.
+268 -378
View File
@@ -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<ProjectDeviceSyncField, string> = {
name: "Technischer Name",
@@ -59,28 +61,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
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<Record<string, string>>({
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<ProjectDeviceDto | null>(null);
const [projectDeviceQuery, setProjectDeviceQuery] = useState("");
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
@@ -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<HTMLFormElement>) {
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() {
</section>
) : null}
<section className="col-12 col-lg-4">
<section className="col-12">
<div className="card shadow-sm">
<div className="card-header">Neue Verteilung</div>
<div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateBoard}>
<input
className="form-control"
placeholder="z. B. UV-01"
value={boardName}
onChange={(event) => setBoardName(event.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Verteilung erstellen
</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 className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Verteilungen</span>
<span className="badge text-bg-secondary ms-2">{boardCount}</span>
</div>
<button
className="btn btn-sm btn-primary"
onClick={() => setStructureModal("board")}
type="button"
>
Verteilung hinzufügen
</button>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
@@ -638,24 +585,20 @@ export default function ProjectDetailPage() {
</div>
</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-header d-flex justify-content-between">
<span>Etagen</span>
<span className="badge text-bg-secondary">{floorCount}</span>
</div>
<div className="card-body border-bottom">
<form className="vstack gap-2" onSubmit={handleCreateFloor}>
<input
className="form-control"
placeholder="z. B. EG"
value={floorName}
onChange={(event) => setFloorName(event.target.value)}
/>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Etage hinzufügen
</button>
</form>
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Etagen</span>
<span className="badge text-bg-secondary ms-2">{floorCount}</span>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => setStructureModal("floor")}
type="button"
>
Hinzufügen
</button>
</div>
<ul className="list-group list-group-flush">
{floors.map((floor) => (
@@ -670,50 +613,20 @@ export default function ProjectDetailPage() {
</div>
</section>
<section className="col-12 col-xl-8">
<section className="col-12 col-lg-7">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between">
<span>Räume</span>
<span className="badge text-bg-secondary">{roomCount}</span>
</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateRoom}>
<div className="col-12 col-lg-3">
<input
className="form-control"
placeholder="Raumnummer"
value={roomNumber}
onChange={(event) => setRoomNumber(event.target.value)}
/>
</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 className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Räume</span>
<span className="badge text-bg-secondary ms-2">{roomCount}</span>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={() => setStructureModal("room")}
type="button"
>
Hinzufügen
</button>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
@@ -747,167 +660,26 @@ export default function ProjectDetailPage() {
</div>
<section className="card shadow-sm mt-4">
<div className="card-header d-flex justify-content-between">
<span>Projektgeräte / Verbraucher</span>
<span className="badge text-bg-secondary">{projectDeviceCount}</span>
</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateProjectDevice}>
<div className="col-12 col-md-2">
<input
className="form-control"
placeholder="Interner Name"
value={projectDeviceForm.name}
onChange={(event) =>
setProjectDeviceForm((current) => ({ ...current, name: event.target.value }))
}
/>
</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>
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<span>Projektgeräte</span>
<span className="badge text-bg-secondary ms-2">{projectDeviceCount}</span>
</div>
<div className="d-flex gap-2">
<input
aria-label="Projektgeräte durchsuchen"
className="form-control form-control-sm"
onChange={(event) => setProjectDeviceQuery(event.target.value)}
placeholder="Geräte durchsuchen"
type="search"
value={projectDeviceQuery}
/>
<button
className="btn btn-outline-primary"
className="btn btn-sm btn-primary text-nowrap"
onClick={() => setIsProjectDeviceModalOpen(true)}
type="button"
onClick={handleCopyGlobalToProject}
disabled={!selectedGlobalDeviceId || isSaving}
>
Nach Projekt kopieren
Gerät hinzufügen
</button>
</div>
</div>
@@ -915,64 +687,51 @@ export default function ProjectDetailPage() {
<table className="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th>Interner Name</th>
<th>Anzeigename</th>
<th>Phasenart</th>
<th>Kategorie</th>
<th>Gerät</th>
<th>Anschluss</th>
<th>Leistung</th>
<th>Kostengruppe</th>
<th>Anzahl</th>
<th>Leistung je Stück [kW]</th>
<th>GZF</th>
<th>Gesamtleistung [kW]</th>
<th>Aktionen</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{projectDevices.map((device) => (
{visibleProjectDevices.map((device) => (
<tr key={device.id}>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.name}
onBlur={(event) =>
event.target.value !== device.name
? handleQuickUpdateProjectDevice(device, "name", event.target.value)
: undefined
}
/>
<strong>{device.displayName}</strong>
<div className="small text-secondary">
{device.name}
{device.category ? ` · ${device.category}` : ""}
</div>
</td>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.displayName}
onBlur={(event) =>
event.target.value !== device.displayName
? handleQuickUpdateProjectDevice(device, "displayName", event.target.value)
: undefined
}
/>
{device.phaseType === "three_phase" ? "3-phasig" : "1-phasig"}
{device.connectionKind ? (
<div className="small text-secondary">
{device.connectionKind}
</div>
) : null}
</td>
<td>{device.phaseType === "three_phase" ? "3-ph" : "1-ph"}</td>
<td>
<input
className="form-control form-control-sm"
defaultValue={device.category ?? ""}
onBlur={(event) =>
event.target.value !== (device.category ?? "")
? handleQuickUpdateProjectDevice(device, "category", event.target.value)
: undefined
}
/>
{device.totalPower} kW
<div className="small text-secondary">
{device.quantity} × {device.powerPerUnit} kW · GZF{" "}
{device.simultaneityFactor}
</div>
</td>
<td>{device.costGroup ?? "-"}</td>
<td>{device.quantity}</td>
<td>{device.powerPerUnit}</td>
<td>{device.simultaneityFactor}</td>
<td>{device.totalPower}</td>
<td>
<div className="btn-group btn-group-sm">
<td className="text-end">
<div className="d-inline-flex flex-wrap justify-content-end gap-1">
<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"
onClick={() => void openProjectDeviceSyncPreview(device.id)}
disabled={isSaving}
@@ -980,7 +739,7 @@ export default function ProjectDetailPage() {
Verknüpfungen
</button>
<button
className="btn btn-outline-secondary"
className="btn btn-sm btn-outline-secondary"
type="button"
onClick={() => handleCopyProjectToGlobal(device.id)}
disabled={isSaving}
@@ -988,7 +747,7 @@ export default function ProjectDetailPage() {
Nach global kopieren
</button>
<button
className="btn btn-outline-danger"
className="btn btn-sm btn-outline-danger"
type="button"
onClick={() => handleDeleteProjectDevice(device.id)}
disabled={isSaving}
@@ -999,10 +758,12 @@ export default function ProjectDetailPage() {
</td>
</tr>
))}
{!projectDevices.length ? (
{!visibleProjectDevices.length ? (
<tr>
<td colSpan={10} className="text-center text-secondary py-4">
Noch keine Projektgeräte vorhanden.
<td colSpan={5} className="text-center text-secondary py-4">
{projectDevices.length
? "Keine Projektgeräte entsprechen der Suche."
: "Noch keine Projektgeräte vorhanden."}
</td>
</tr>
) : null}
@@ -1121,6 +882,135 @@ export default function ProjectDetailPage() {
) : null}
</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 ? (
<ProjectSettingsModal
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 { renderToStaticMarkup } from "react-dom/server";
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 {
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", () => {
it("uses the revision-safe timeline and snapshot routes", async () => {
const requests: Array<{