forked from jappel/leistungsbilanz-ts
1136 lines
41 KiB
TypeScript
1136 lines
41 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useParams } from "next/navigation";
|
||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||
import {
|
||
copyGlobalDeviceToProject,
|
||
copyProjectDeviceToGlobal,
|
||
createDistributionBoard,
|
||
createFloor,
|
||
createProjectDevice,
|
||
createRoom,
|
||
deleteProjectDevice,
|
||
disconnectProjectDeviceRows,
|
||
getProjectDeviceSyncPreview,
|
||
listCircuitLists,
|
||
listDistributionBoards,
|
||
listFloors,
|
||
listGlobalDevices,
|
||
listProjectDevices,
|
||
listProjects,
|
||
listRooms,
|
||
reconnectProjectDeviceRows,
|
||
restoreProjectDeviceRows,
|
||
synchronizeProjectDeviceRows,
|
||
updateProjectDevice,
|
||
updateProjectSettings,
|
||
} from "../../../frontend/utils/api";
|
||
import type {
|
||
CircuitListDto,
|
||
CreateProjectDeviceInput,
|
||
DistributionBoardDto,
|
||
FloorDto,
|
||
GlobalDeviceDto,
|
||
ProjectDeviceDto,
|
||
ProjectDeviceSyncPreviewDto,
|
||
ProjectDeviceSyncRestoreRowDto,
|
||
ProjectDto,
|
||
RoomDto,
|
||
} from "../../../frontend/types";
|
||
import {
|
||
projectDeviceSyncFields,
|
||
type ProjectDeviceSyncField,
|
||
} from "../../../shared/constants/project-device-sync-fields";
|
||
|
||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||
name: "Technischer Name",
|
||
displayName: "Anzeigename",
|
||
phaseType: "Phasenart",
|
||
connectionKind: "Anschlussart",
|
||
costGroup: "Kostengruppe",
|
||
category: "Kategorie",
|
||
quantity: "Anzahl",
|
||
powerPerUnit: "Leistung je Stück",
|
||
simultaneityFactor: "Gleichzeitigkeitsfaktor",
|
||
cosPhi: "cos Phi",
|
||
remark: "Bemerkung",
|
||
};
|
||
|
||
type ProjectDeviceUndoState =
|
||
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] }
|
||
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] };
|
||
|
||
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("");
|
||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||
const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
|
||
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
|
||
const [floors, setFloors] = useState<FloorDto[]>([]);
|
||
const [rooms, setRooms] = useState<RoomDto[]>([]);
|
||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
||
const [boardName, setBoardName] = useState("");
|
||
const [floorName, setFloorName] = useState("");
|
||
const [roomNumber, setRoomNumber] = useState("");
|
||
const [roomName, setRoomName] = useState("");
|
||
const [roomFloorId, setRoomFloorId] = useState("");
|
||
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState("230");
|
||
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState("400");
|
||
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 [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
|
||
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
|
||
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
|
||
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
|
||
useEffect(() => {
|
||
setProjectId(params.projectId);
|
||
}, [params.projectId]);
|
||
|
||
useEffect(() => {
|
||
if (!projectId) {
|
||
return;
|
||
}
|
||
Promise.all([
|
||
listProjects(),
|
||
listDistributionBoards(projectId),
|
||
listCircuitLists(projectId),
|
||
listFloors(projectId),
|
||
listRooms(projectId),
|
||
listProjectDevices(projectId),
|
||
listGlobalDevices(),
|
||
])
|
||
.then(([
|
||
projects,
|
||
distributionBoards,
|
||
loadedCircuitLists,
|
||
loadedFloors,
|
||
loadedRooms,
|
||
loadedProjectDevices,
|
||
loadedGlobalDevices,
|
||
]) => {
|
||
const currentProject = projects.find((item) => item.id === projectId) ?? null;
|
||
setProject(currentProject);
|
||
if (currentProject) {
|
||
setSinglePhaseVoltageV(String(currentProject.singlePhaseVoltageV));
|
||
setThreePhaseVoltageV(String(currentProject.threePhaseVoltageV));
|
||
}
|
||
setBoards(distributionBoards);
|
||
setCircuitLists(loadedCircuitLists);
|
||
setFloors(loadedFloors);
|
||
setRooms(loadedRooms);
|
||
setProjectDevices(loadedProjectDevices);
|
||
setGlobalDevices(loadedGlobalDevices);
|
||
setError(null);
|
||
})
|
||
.catch((err: unknown) =>
|
||
setError(err instanceof Error ? err.message : "Projektdaten konnten nicht geladen werden.")
|
||
);
|
||
}, [projectId]);
|
||
|
||
const boardCount = useMemo(() => boards.length, [boards.length]);
|
||
const floorCount = useMemo(() => floors.length, [floors.length]);
|
||
const roomCount = useMemo(() => rooms.length, [rooms.length]);
|
||
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
|
||
const floorById = useMemo(() => new Map(floors.map((item) => [item.id, item])), [floors]);
|
||
const circuitListByBoardId = useMemo(
|
||
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
|
||
[circuitLists]
|
||
);
|
||
|
||
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!projectId || !boardName.trim()) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const created = await createDistributionBoard(projectId, boardName.trim());
|
||
setBoards((current) => [...current, created]);
|
||
setCircuitLists(await listCircuitLists(projectId));
|
||
setBoardName("");
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Verteilung konnte nicht erstellt werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleCreateFloor(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!projectId || !floorName.trim()) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const created = await createFloor(projectId, { name: floorName.trim() });
|
||
setFloors((current) => [...current, created]);
|
||
setFloorName("");
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Etage konnte nicht erstellt werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleCreateRoom(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!projectId || !roomNumber.trim() || !roomName.trim()) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const created = await createRoom(projectId, {
|
||
floorId: roomFloorId || undefined,
|
||
roomNumber: roomNumber.trim(),
|
||
roomName: roomName.trim(),
|
||
});
|
||
setRooms((current) => [...current, created]);
|
||
setRoomNumber("");
|
||
setRoomName("");
|
||
setRoomFloorId("");
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Raum konnte nicht erstellt werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleSaveProjectSettings() {
|
||
if (!projectId) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const updated = await updateProjectSettings(projectId, {
|
||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||
});
|
||
setProject(updated);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Projekteigenschaften konnten nicht gespeichert werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleCreateProjectDevice(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!projectId || !projectDeviceForm.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 {
|
||
const created = await createProjectDevice(projectId, payload);
|
||
setProjectDevices((current) => [...current, created]);
|
||
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 ?? "",
|
||
});
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht erstellt werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleDeleteProjectDevice(projectDeviceId: string) {
|
||
if (!projectId) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
await deleteProjectDevice(projectId, projectDeviceId);
|
||
setProjectDevices((current) => current.filter((item) => item.id !== projectDeviceId));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht gelöscht werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleQuickUpdateProjectDevice(
|
||
device: ProjectDeviceDto,
|
||
key: "name" | "displayName" | "category",
|
||
value: string
|
||
) {
|
||
if (!projectId) {
|
||
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,
|
||
};
|
||
|
||
try {
|
||
const updated = await updateProjectDevice(projectId, device.id, payload);
|
||
setProjectDevices((current) => current.map((item) => (item.id === device.id ? updated : item)));
|
||
await openProjectDeviceSyncPreview(updated.id);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht aktualisiert werden.");
|
||
}
|
||
}
|
||
|
||
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
|
||
if (!projectId) {
|
||
return;
|
||
}
|
||
try {
|
||
setProjectDeviceUndo(null);
|
||
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
|
||
setSyncPreview(preview);
|
||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||
const differentFields = new Set(
|
||
preview.rows.flatMap((row) => row.differences.map((difference) => difference.field))
|
||
);
|
||
setSelectedSyncFields(
|
||
projectDeviceSyncFields.filter((field) => field !== "displayName" && differentFields.has(field))
|
||
);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht geladen werden.");
|
||
}
|
||
}
|
||
|
||
function toggleSyncRow(rowId: string) {
|
||
setSelectedSyncRowIds((current) =>
|
||
current.includes(rowId) ? current.filter((id) => id !== rowId) : [...current, rowId]
|
||
);
|
||
}
|
||
|
||
function toggleSyncField(field: ProjectDeviceSyncField) {
|
||
setSelectedSyncFields((current) =>
|
||
current.includes(field) ? current.filter((entry) => entry !== field) : [...current, field]
|
||
);
|
||
}
|
||
|
||
async function handleSynchronizeProjectDeviceRows() {
|
||
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const result = await synchronizeProjectDeviceRows(
|
||
projectId,
|
||
syncPreview.projectDevice.id,
|
||
selectedSyncRowIds,
|
||
selectedSyncFields
|
||
);
|
||
setSyncPreview(result.preview);
|
||
setSelectedSyncRowIds(
|
||
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
|
||
);
|
||
setProjectDeviceUndo({
|
||
kind: "synchronize",
|
||
projectDeviceId: syncPreview.projectDevice.id,
|
||
rows: result.undo.rows,
|
||
});
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleDisconnectProjectDeviceRows() {
|
||
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const result = await disconnectProjectDeviceRows(
|
||
projectId,
|
||
syncPreview.projectDevice.id,
|
||
selectedSyncRowIds
|
||
);
|
||
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
|
||
setProjectDeviceUndo({
|
||
kind: "disconnect",
|
||
projectDeviceId: syncPreview.projectDevice.id,
|
||
rowIds: result.undo.rowIds,
|
||
});
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleUndoProjectDeviceOperation() {
|
||
if (!projectId || !projectDeviceUndo) {
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const preview =
|
||
projectDeviceUndo.kind === "synchronize"
|
||
? await restoreProjectDeviceRows(
|
||
projectId,
|
||
projectDeviceUndo.projectDeviceId,
|
||
projectDeviceUndo.rows
|
||
)
|
||
: await reconnectProjectDeviceRows(
|
||
projectId,
|
||
projectDeviceUndo.projectDeviceId,
|
||
projectDeviceUndo.rowIds
|
||
);
|
||
setSyncPreview(preview);
|
||
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
|
||
setProjectDeviceUndo(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleCopyGlobalToProject() {
|
||
if (!projectId || !selectedGlobalDeviceId) {
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const created = await copyGlobalDeviceToProject(projectId, selectedGlobalDeviceId);
|
||
setProjectDevices((current) => [...current, created]);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Globales Gerät konnte nicht ins Projekt kopiert werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleCopyProjectToGlobal(projectDeviceId: string) {
|
||
if (!projectId) {
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
setError(null);
|
||
try {
|
||
const created = await copyProjectDeviceToGlobal(projectId, projectDeviceId);
|
||
setGlobalDevices((current) => [...current, created]);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Projektgerät konnte nicht global kopiert werden.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="container py-4">
|
||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||
<div>
|
||
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
|
||
<p className="text-secondary mb-0">Verteilerübersicht und Einstieg in die Stromkreislisten</p>
|
||
</div>
|
||
<Link className="btn btn-outline-secondary" href="/projects">
|
||
Zur Projektseite
|
||
</Link>
|
||
</div>
|
||
|
||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||
|
||
<div className="row g-4">
|
||
<section className="col-12">
|
||
<div className="card shadow-sm">
|
||
<div className="card-header">Projekteigenschaften</div>
|
||
<div className="card-body">
|
||
<div className="row g-3 align-items-end">
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label">Standardspannung 1-phasig [V]</label>
|
||
<input
|
||
className="form-control"
|
||
type="number"
|
||
min="1"
|
||
value={singlePhaseVoltageV}
|
||
onChange={(event) => setSinglePhaseVoltageV(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label">Standardspannung 3-phasig [V]</label>
|
||
<input
|
||
className="form-control"
|
||
type="number"
|
||
min="1"
|
||
value={threePhaseVoltageV}
|
||
onChange={(event) => setThreePhaseVoltageV(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<button
|
||
className="btn btn-primary w-100"
|
||
type="button"
|
||
onClick={handleSaveProjectSettings}
|
||
disabled={isSaving}
|
||
>
|
||
Projekteigenschaften speichern
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="col-12 col-lg-4">
|
||
<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>
|
||
<div className="table-responsive">
|
||
<table className="table table-hover align-middle mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Verteilung</th>
|
||
<th className="text-end">Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{boards.map((board) => {
|
||
const circuitList = circuitListByBoardId.get(board.id);
|
||
return (
|
||
<tr key={board.id}>
|
||
<td>{board.name}</td>
|
||
<td className="text-end">
|
||
<div className="d-inline-flex gap-2">
|
||
{circuitList ? (
|
||
<Link
|
||
className="btn btn-sm btn-primary"
|
||
href={`/projects/${projectId}/circuit-lists/${circuitList.id}/tree-edit`}
|
||
>
|
||
Stromkreisliste öffnen
|
||
</Link>
|
||
) : (
|
||
<span className="text-secondary small align-self-center">
|
||
Keine Stromkreisliste vorhanden
|
||
</span>
|
||
)}
|
||
<Link
|
||
className="btn btn-sm btn-outline-secondary"
|
||
href={`/projects/${projectId}/circuit-lists?boardId=${board.id}`}
|
||
>
|
||
Legacy
|
||
</Link>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{!boards.length ? (
|
||
<tr>
|
||
<td colSpan={2} className="text-center text-secondary py-4">
|
||
Noch keine Verteilungen vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="col-12 col-xl-4">
|
||
<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>
|
||
<ul className="list-group list-group-flush">
|
||
{floors.map((floor) => (
|
||
<li className="list-group-item" key={floor.id}>
|
||
{floor.name}
|
||
</li>
|
||
))}
|
||
{!floors.length ? (
|
||
<li className="list-group-item text-secondary">Noch keine Etagen vorhanden.</li>
|
||
) : null}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="col-12 col-xl-8">
|
||
<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>
|
||
<div className="table-responsive">
|
||
<table className="table table-hover align-middle mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Raumnummer</th>
|
||
<th>Raumname</th>
|
||
<th>Etage</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rooms.map((room) => (
|
||
<tr key={room.id}>
|
||
<td>{room.roomNumber}</td>
|
||
<td>{room.roomName}</td>
|
||
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
|
||
</tr>
|
||
))}
|
||
{!rooms.length ? (
|
||
<tr>
|
||
<td colSpan={3} className="text-center text-secondary py-4">
|
||
Noch keine Räume vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</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>
|
||
<button
|
||
className="btn btn-outline-primary"
|
||
type="button"
|
||
onClick={handleCopyGlobalToProject}
|
||
disabled={!selectedGlobalDeviceId || isSaving}
|
||
>
|
||
Nach Projekt kopieren
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<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>Kostengruppe</th>
|
||
<th>Anzahl</th>
|
||
<th>Leistung je Stück [kW]</th>
|
||
<th>GZF</th>
|
||
<th>Gesamtleistung [kW]</th>
|
||
<th>Aktionen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{projectDevices.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
|
||
}
|
||
/>
|
||
</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
|
||
}
|
||
/>
|
||
</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
|
||
}
|
||
/>
|
||
</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">
|
||
<button
|
||
className="btn btn-outline-primary"
|
||
type="button"
|
||
onClick={() => void openProjectDeviceSyncPreview(device.id)}
|
||
disabled={isSaving}
|
||
>
|
||
Verknüpfungen
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-secondary"
|
||
type="button"
|
||
onClick={() => handleCopyProjectToGlobal(device.id)}
|
||
disabled={isSaving}
|
||
>
|
||
Nach global kopieren
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-danger"
|
||
type="button"
|
||
onClick={() => handleDeleteProjectDevice(device.id)}
|
||
disabled={isSaving}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{!projectDevices.length ? (
|
||
<tr>
|
||
<td colSpan={10} className="text-center text-secondary py-4">
|
||
Noch keine Projektgeräte vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{syncPreview ? (
|
||
<div className="card-body border-top">
|
||
<div className="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3">
|
||
<div>
|
||
<h3 className="h6 mb-1">Verknüpfte Zeilen: {syncPreview.projectDevice.displayName}</h3>
|
||
<p className="text-secondary small mb-0">
|
||
Nur ausgewählte Felder und Zeilen werden übernommen. Der Anzeigename bleibt standardmäßig lokal.
|
||
</p>
|
||
</div>
|
||
<button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSyncPreview(null)}>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
|
||
<div className="d-flex flex-wrap gap-3 mb-3">
|
||
{projectDeviceSyncFields.map((field) => (
|
||
<label className="form-check" key={field}>
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
checked={selectedSyncFields.includes(field)}
|
||
onChange={() => toggleSyncField(field)}
|
||
/>
|
||
<span className="form-check-label">
|
||
{projectDeviceSyncFieldLabels[field]}
|
||
{field === "displayName" ? " (lokal)" : ""}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<div className="table-responsive border rounded mb-3">
|
||
<table className="table table-sm align-middle mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Auswahl</th>
|
||
<th>Verteilung / Stromkreis</th>
|
||
<th>Gerätezeile</th>
|
||
<th>Abweichungen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{syncPreview.rows.map((row) => (
|
||
<tr key={row.rowId}>
|
||
<td>
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
checked={selectedSyncRowIds.includes(row.rowId)}
|
||
onChange={() => toggleSyncRow(row.rowId)}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<strong>{row.distributionBoardName}</strong>
|
||
<div className="small text-secondary">
|
||
{row.equipmentIdentifier}
|
||
{row.circuitDisplayName ? ` – ${row.circuitDisplayName}` : ""}
|
||
</div>
|
||
</td>
|
||
<td>{row.rowDisplayName}</td>
|
||
<td>
|
||
<div className="d-flex flex-wrap gap-1">
|
||
{row.differences.map((difference) => (
|
||
<span
|
||
className={`badge ${difference.isOverridden ? "text-bg-warning" : "text-bg-light"}`}
|
||
key={difference.field}
|
||
title={difference.isOverridden ? "Lokal überschrieben" : undefined}
|
||
>
|
||
{projectDeviceSyncFieldLabels[difference.field]}: {String(difference.currentValue ?? "–")} →{" "}
|
||
{String(difference.sourceValue ?? "–")}
|
||
</span>
|
||
))}
|
||
{row.differences.length === 0 ? (
|
||
<span className="text-success small">Aktuell</span>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{syncPreview.rows.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={4} className="text-center text-secondary py-3">
|
||
Keine verknüpften Circuit-First-Gerätezeilen vorhanden.
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="d-flex flex-wrap gap-2">
|
||
{projectDeviceUndo ? (
|
||
<button
|
||
className="btn btn-outline-secondary"
|
||
type="button"
|
||
onClick={() => void handleUndoProjectDeviceOperation()}
|
||
disabled={isSaving}
|
||
>
|
||
Letzte Aktion rückgängig
|
||
</button>
|
||
) : null}
|
||
<button
|
||
className="btn btn-primary"
|
||
type="button"
|
||
onClick={() => void handleSynchronizeProjectDeviceRows()}
|
||
disabled={isSaving || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0}
|
||
>
|
||
Auswahl synchronisieren
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-danger"
|
||
type="button"
|
||
onClick={() => void handleDisconnectProjectDeviceRows()}
|
||
disabled={isSaving || selectedSyncRowIds.length === 0}
|
||
>
|
||
Auswahl trennen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<div className="mt-4">
|
||
<Link className="btn btn-primary" href={`/projects/${projectId}/circuit-lists`}>
|
||
3 parallele Stromkreislisten öffnen
|
||
</Link>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
|