Streamline project management UI
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user