Add explicit project device sync

This commit is contained in:
2026-07-22 19:38:21 +02:00
parent 9d07ed9856
commit 6f8b292b6b
15 changed files with 771 additions and 5 deletions
+213
View File
@@ -11,6 +11,8 @@ import {
createProjectDevice,
createRoom,
deleteProjectDevice,
disconnectProjectDeviceRows,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
listFloors,
@@ -18,6 +20,7 @@ import {
listProjectDevices,
listProjects,
listRooms,
synchronizeProjectDeviceRows,
updateProjectDevice,
updateProjectSettings,
} from "../../../frontend/utils/api";
@@ -28,9 +31,28 @@ import type {
FloorDto,
GlobalDeviceDto,
ProjectDeviceDto,
ProjectDeviceSyncPreviewDto,
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",
};
const emptyProjectDevice: CreateProjectDeviceInput = {
name: "",
@@ -85,6 +107,9 @@ export default function ProjectDetailPage() {
remark: "",
});
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -311,11 +336,81 @@ export default function ProjectDetailPage() {
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 {
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 preview = await synchronizeProjectDeviceRows(
projectId,
syncPreview.projectDevice.id,
selectedSyncRowIds,
selectedSyncFields
);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
} 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 {
await disconnectProjectDeviceRows(projectId, syncPreview.projectDevice.id, selectedSyncRowIds);
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id);
} catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject() {
if (!projectId || !selectedGlobalDeviceId) {
return;
@@ -815,6 +910,14 @@ export default function ProjectDetailPage() {
<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"
@@ -845,6 +948,116 @@ export default function ProjectDetailPage() {
</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">
<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">