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">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../client.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||
|
||||
export class CircuitDeviceRowRepository {
|
||||
async findById(rowId: string) {
|
||||
@@ -25,6 +28,50 @@ export class CircuitDeviceRowRepository {
|
||||
.orderBy(asc(circuitDeviceRows.sortOrder));
|
||||
}
|
||||
|
||||
async listLinkedByProjectDevice(projectId: string, projectDeviceId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: circuitDeviceRows.id,
|
||||
circuitId: circuitDeviceRows.circuitId,
|
||||
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
||||
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
|
||||
sortOrder: circuitDeviceRows.sortOrder,
|
||||
name: circuitDeviceRows.name,
|
||||
displayName: circuitDeviceRows.displayName,
|
||||
phaseType: circuitDeviceRows.phaseType,
|
||||
connectionKind: circuitDeviceRows.connectionKind,
|
||||
costGroup: circuitDeviceRows.costGroup,
|
||||
category: circuitDeviceRows.category,
|
||||
level: circuitDeviceRows.level,
|
||||
roomId: circuitDeviceRows.roomId,
|
||||
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||
quantity: circuitDeviceRows.quantity,
|
||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||
cosPhi: circuitDeviceRows.cosPhi,
|
||||
remark: circuitDeviceRows.remark,
|
||||
overriddenFields: circuitDeviceRows.overriddenFields,
|
||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||
circuitDisplayName: circuits.displayName,
|
||||
circuitListId: circuitLists.id,
|
||||
circuitListName: circuitLists.name,
|
||||
distributionBoardId: distributionBoards.id,
|
||||
distributionBoardName: distributionBoards.name,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
|
||||
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
|
||||
.innerJoin(distributionBoards, eq(circuitLists.distributionBoardId, distributionBoards.id))
|
||||
.where(
|
||||
and(
|
||||
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
circuitId: string;
|
||||
linkedProjectDeviceId?: string;
|
||||
|
||||
@@ -14,6 +14,11 @@ import type {
|
||||
UpdateCircuitInput,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
import { CircuitNumberingService } from "./circuit-numbering.service.js";
|
||||
import { projectDeviceSyncFields } from "../../shared/constants/project-device-sync-fields.js";
|
||||
import {
|
||||
parseOverriddenFields,
|
||||
serializeOverriddenFields,
|
||||
} from "./project-device-sync.service.js";
|
||||
|
||||
export class CircuitWriteService {
|
||||
private readonly circuitRepository: CircuitRepository;
|
||||
@@ -218,6 +223,21 @@ export class CircuitWriteService {
|
||||
throw new Error("Invalid device row id.");
|
||||
}
|
||||
await this.assertValidLinkedProjectDevice(current.circuitId, input.linkedProjectDeviceId);
|
||||
let overriddenFields = input.overriddenFields ?? current.overriddenFields ?? undefined;
|
||||
if (current.linkedProjectDeviceId && input.overriddenFields === undefined) {
|
||||
const overrides = new Set(parseOverriddenFields(current.overriddenFields));
|
||||
const inputValues = input as Record<string, unknown>;
|
||||
const currentValues = current as unknown as Record<string, unknown>;
|
||||
for (const field of projectDeviceSyncFields) {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(input, field) &&
|
||||
(inputValues[field] ?? null) !== (currentValues[field] ?? null)
|
||||
) {
|
||||
overrides.add(field);
|
||||
}
|
||||
}
|
||||
overriddenFields = serializeOverriddenFields(overrides);
|
||||
}
|
||||
await this.deviceRowRepository.update(rowId, {
|
||||
linkedProjectDeviceId: input.linkedProjectDeviceId ?? current.linkedProjectDeviceId ?? undefined,
|
||||
name: input.name ?? current.name,
|
||||
@@ -235,7 +255,7 @@ export class CircuitWriteService {
|
||||
simultaneityFactor: input.simultaneityFactor ?? current.simultaneityFactor,
|
||||
cosPhi: input.cosPhi ?? current.cosPhi ?? undefined,
|
||||
remark: input.remark ?? current.remark ?? undefined,
|
||||
overriddenFields: input.overriddenFields ?? current.overriddenFields ?? undefined,
|
||||
overriddenFields,
|
||||
});
|
||||
return this.deviceRowRepository.findById(rowId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import {
|
||||
projectDeviceSyncFields,
|
||||
type ProjectDeviceSyncField,
|
||||
} from "../../shared/constants/project-device-sync-fields.js";
|
||||
|
||||
type ProjectDevice = NonNullable<Awaited<ReturnType<ProjectDeviceRepository["findById"]>>>;
|
||||
type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProjectDevice"]>>[number];
|
||||
|
||||
type SyncDependencies = {
|
||||
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
|
||||
deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice" | "update">;
|
||||
};
|
||||
|
||||
export function parseOverriddenFields(value: string | null | undefined): ProjectDeviceSyncField[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((field): field is ProjectDeviceSyncField =>
|
||||
projectDeviceSyncFields.includes(field as ProjectDeviceSyncField)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeOverriddenFields(fields: Iterable<ProjectDeviceSyncField>): string | undefined {
|
||||
const fieldSet = new Set(fields);
|
||||
const unique = projectDeviceSyncFields.filter((field) => fieldSet.has(field));
|
||||
return unique.length > 0 ? JSON.stringify(unique) : undefined;
|
||||
}
|
||||
|
||||
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
|
||||
return projectDevice[field];
|
||||
}
|
||||
|
||||
function valuesEqual(left: unknown, right: unknown) {
|
||||
return (left ?? null) === (right ?? null);
|
||||
}
|
||||
|
||||
function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
|
||||
return projectDeviceSyncFields
|
||||
.filter((field) => !valuesEqual(row[field], sourceValue(projectDevice, field)))
|
||||
.map((field) => ({
|
||||
field,
|
||||
currentValue: row[field] ?? null,
|
||||
sourceValue: sourceValue(projectDevice, field) ?? null,
|
||||
isOverridden: parseOverriddenFields(row.overriddenFields).includes(field),
|
||||
}));
|
||||
}
|
||||
|
||||
export class ProjectDeviceSyncService {
|
||||
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
|
||||
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
|
||||
|
||||
constructor(deps?: Partial<SyncDependencies>) {
|
||||
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
|
||||
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
|
||||
}
|
||||
|
||||
async getPreview(projectId: string, projectDeviceId: string) {
|
||||
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||
if (!projectDevice) {
|
||||
throw new Error("Project device not found.");
|
||||
}
|
||||
const rows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||
|
||||
return {
|
||||
projectDevice: {
|
||||
id: projectDevice.id,
|
||||
name: projectDevice.name,
|
||||
displayName: projectDevice.displayName,
|
||||
},
|
||||
rows: rows.map((row) => ({
|
||||
rowId: row.id,
|
||||
circuitId: row.circuitId,
|
||||
equipmentIdentifier: row.equipmentIdentifier,
|
||||
circuitDisplayName: row.circuitDisplayName,
|
||||
circuitListId: row.circuitListId,
|
||||
circuitListName: row.circuitListName,
|
||||
distributionBoardId: row.distributionBoardId,
|
||||
distributionBoardName: row.distributionBoardName,
|
||||
rowDisplayName: row.displayName,
|
||||
overriddenFields: parseOverriddenFields(row.overriddenFields),
|
||||
differences: buildDifferences(projectDevice, row),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async synchronize(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[],
|
||||
fields: ProjectDeviceSyncField[]
|
||||
) {
|
||||
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
|
||||
if (!projectDevice) {
|
||||
throw new Error("Project device not found.");
|
||||
}
|
||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||
const selectedFields = [...new Set(fields)];
|
||||
|
||||
for (const row of selectedRows) {
|
||||
const next = this.copyRow(row);
|
||||
for (const field of selectedFields) {
|
||||
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined });
|
||||
}
|
||||
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
|
||||
(field) => !selectedFields.includes(field)
|
||||
);
|
||||
next.overriddenFields = serializeOverriddenFields(remainingOverrides);
|
||||
await this.deviceRowRepository.update(row.id, next);
|
||||
}
|
||||
|
||||
return this.getPreview(projectId, projectDeviceId);
|
||||
}
|
||||
|
||||
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
|
||||
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
|
||||
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
|
||||
|
||||
for (const row of selectedRows) {
|
||||
await this.deviceRowRepository.update(row.id, {
|
||||
...this.copyRow(row),
|
||||
linkedProjectDeviceId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return { disconnectedRowIds: selectedRows.map((row) => row.id) };
|
||||
}
|
||||
|
||||
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
|
||||
const uniqueRowIds = [...new Set(rowIds)];
|
||||
const byId = new Map(linkedRows.map((row) => [row.id, row]));
|
||||
const selectedRows = uniqueRowIds.map((rowId) => byId.get(rowId)).filter(Boolean) as LinkedRow[];
|
||||
if (selectedRows.length !== uniqueRowIds.length) {
|
||||
throw new Error("One or more rows are not linked to this project device.");
|
||||
}
|
||||
return selectedRows;
|
||||
}
|
||||
|
||||
private copyRow(row: LinkedRow) {
|
||||
return {
|
||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
phaseType: row.phaseType ?? undefined,
|
||||
connectionKind: row.connectionKind ?? undefined,
|
||||
costGroup: row.costGroup ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
level: row.level ?? undefined,
|
||||
roomId: row.roomId ?? undefined,
|
||||
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
||||
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
||||
quantity: row.quantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi ?? undefined,
|
||||
remark: row.remark ?? undefined,
|
||||
overriddenFields: row.overriddenFields ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,36 @@ export interface CreateProjectDeviceInput {
|
||||
voltageV?: number;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncDifferenceDto {
|
||||
field: ProjectDeviceSyncField;
|
||||
currentValue: string | number | null;
|
||||
sourceValue: string | number | null;
|
||||
isOverridden: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncRowDto {
|
||||
rowId: string;
|
||||
circuitId: string;
|
||||
equipmentIdentifier: string;
|
||||
circuitDisplayName: string | null;
|
||||
circuitListId: string;
|
||||
circuitListName: string;
|
||||
distributionBoardId: string;
|
||||
distributionBoardName: string;
|
||||
rowDisplayName: string;
|
||||
overriddenFields: ProjectDeviceSyncField[];
|
||||
differences: ProjectDeviceSyncDifferenceDto[];
|
||||
}
|
||||
|
||||
export interface ProjectDeviceSyncPreviewDto {
|
||||
projectDevice: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
};
|
||||
rows: ProjectDeviceSyncRowDto[];
|
||||
}
|
||||
|
||||
export interface CircuitTreeDeviceRowDto {
|
||||
id: string;
|
||||
linkedProjectDeviceId?: string;
|
||||
@@ -296,3 +326,4 @@ export interface CreateCircuitDeviceRowInputDto {
|
||||
}
|
||||
|
||||
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
FloorDto,
|
||||
GlobalDeviceDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectDeviceSyncPreviewDto,
|
||||
ProjectDto,
|
||||
RoomDto,
|
||||
UpdateConsumerInput,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
CreateCircuitDeviceRowInputDto,
|
||||
UpdateCircuitDeviceRowInputDto,
|
||||
} from "../types";
|
||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
@@ -285,3 +287,38 @@ export function copyGlobalDeviceToProject(projectId: string, globalDeviceId: str
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectDeviceSyncPreview(projectId: string, projectDeviceId: string) {
|
||||
return request<ProjectDeviceSyncPreviewDto>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/links`
|
||||
);
|
||||
}
|
||||
|
||||
export function synchronizeProjectDeviceRows(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[],
|
||||
fields: ProjectDeviceSyncField[]
|
||||
) {
|
||||
return request<ProjectDeviceSyncPreviewDto>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rowIds, fields }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function disconnectProjectDeviceRows(
|
||||
projectId: string,
|
||||
projectDeviceId: string,
|
||||
rowIds: string[]
|
||||
) {
|
||||
return request<{ disconnectedRowIds: string[] }>(
|
||||
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ rowIds }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
|
||||
import {
|
||||
createProjectDeviceSchema,
|
||||
disconnectProjectDeviceRowsSchema,
|
||||
synchronizeProjectDeviceRowsSchema,
|
||||
updateProjectDeviceSchema,
|
||||
} from "../../shared/validation/project-device.schemas.js";
|
||||
|
||||
const globalDeviceRepository = new GlobalDeviceRepository();
|
||||
const projectDeviceRepository = new ProjectDeviceRepository();
|
||||
const projectDeviceSyncService = new ProjectDeviceSyncService();
|
||||
|
||||
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
@@ -88,3 +92,55 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
||||
|
||||
return res.status(201).json(created);
|
||||
}
|
||||
|
||||
export async function getProjectDeviceSyncPreview(req: Request, res: Response) {
|
||||
const { projectId, projectDeviceId } = req.params;
|
||||
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
try {
|
||||
const preview = await projectDeviceSyncService.getPreview(projectId, projectDeviceId);
|
||||
return res.json(preview);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to load sync preview." });
|
||||
}
|
||||
}
|
||||
|
||||
export async function synchronizeProjectDeviceRows(req: Request, res: Response) {
|
||||
const { projectId, projectDeviceId } = req.params;
|
||||
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
const parsed = synchronizeProjectDeviceRowsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
const preview = await projectDeviceSyncService.synchronize(
|
||||
projectId,
|
||||
projectDeviceId,
|
||||
parsed.data.rowIds,
|
||||
parsed.data.fields
|
||||
);
|
||||
return res.json(preview);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." });
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectProjectDeviceRows(req: Request, res: Response) {
|
||||
const { projectId, projectDeviceId } = req.params;
|
||||
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid parameters" });
|
||||
}
|
||||
const parsed = disconnectProjectDeviceRowsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
try {
|
||||
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds);
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
copyGlobalDeviceToProject,
|
||||
createProjectDevice,
|
||||
deleteProjectDevice,
|
||||
disconnectProjectDeviceRows,
|
||||
getProjectDeviceSyncPreview,
|
||||
listProjectDevicesByProject,
|
||||
synchronizeProjectDeviceRows,
|
||||
updateProjectDevice,
|
||||
} from "../controllers/project-device.controller.js";
|
||||
|
||||
@@ -12,5 +15,8 @@ export const projectDeviceRouter = Router();
|
||||
projectDeviceRouter.get("/projects/:projectId", listProjectDevicesByProject);
|
||||
projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
|
||||
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
|
||||
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
|
||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
|
||||
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
|
||||
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
|
||||
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const projectDeviceSyncFields = [
|
||||
"name",
|
||||
"displayName",
|
||||
"phaseType",
|
||||
"connectionKind",
|
||||
"costGroup",
|
||||
"category",
|
||||
"quantity",
|
||||
"powerPerUnit",
|
||||
"simultaneityFactor",
|
||||
"cosPhi",
|
||||
"remark",
|
||||
] as const;
|
||||
|
||||
export type ProjectDeviceSyncField = (typeof projectDeviceSyncFields)[number];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { projectDeviceSyncFields } from "../constants/project-device-sync-fields.js";
|
||||
|
||||
export const createProjectDeviceSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -18,5 +19,14 @@ export const createProjectDeviceSchema = z.object({
|
||||
|
||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||
|
||||
export const synchronizeProjectDeviceRowsSchema = z.object({
|
||||
rowIds: z.array(z.string().min(1)).min(1),
|
||||
fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
|
||||
});
|
||||
|
||||
export const disconnectProjectDeviceRowsSchema = z.object({
|
||||
rowIds: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
|
||||
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
|
||||
|
||||
Reference in New Issue
Block a user