leistungsbilanz-ts/src/app/projects/[projectId]/page.tsx

1125 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)}
</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 Gerätezeilen im neuen Stromkreismodell 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>
</main>
);
}