Add explicit project device sync
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user