Extract circuit grid model
This commit is contained in:
@@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
The circuit-list editor is a circuit-first planning workspace for distribution-board design. It is optimized for spreadsheet-like editing while preserving electrical domain boundaries: circuit-level data stays on circuits, and load rows stay inside circuits.
|
The circuit-list editor is a circuit-first planning workspace for distribution-board design. It is optimized for spreadsheet-like editing while preserving electrical domain boundaries: circuit-level data stays on circuits, and load rows stay inside circuits.
|
||||||
|
|
||||||
|
## Frontend Grid Modules
|
||||||
|
|
||||||
|
- `circuit-tree-editor.tsx` owns React state, API commands, undo/redo and drag-and-drop orchestration.
|
||||||
|
- `circuit-grid-model.ts` owns column metadata, circuit/device cell ownership, value projection, formatting, numeric parsing and block sort values.
|
||||||
|
- `circuit-grid-insertion.ts` resolves insertion intent and insertion sort positions.
|
||||||
|
- `circuit-grid-safety.ts` resolves delete intent, BMK conflicts and cross-section move confirmation requirements.
|
||||||
|
|
||||||
|
The pure grid modules have no React state and are covered by focused unit tests. This keeps circuit/device ownership rules testable while the editor UI is split incrementally.
|
||||||
|
|
||||||
## Domain Model Overview
|
## Domain Model Overview
|
||||||
|
|
||||||
- `CircuitSection`
|
- `CircuitSection`
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts",
|
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts",
|
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "node scripts/db-backup.js",
|
"db:backup": "node scripts/db-backup.js",
|
||||||
|
|||||||
@@ -15,6 +15,26 @@ import {
|
|||||||
requiresCrossSectionMoveConfirmation,
|
requiresCrossSectionMoveConfirmation,
|
||||||
resolveGridDeleteIntent,
|
resolveGridDeleteIntent,
|
||||||
} from "../utils/circuit-grid-safety";
|
} from "../utils/circuit-grid-safety";
|
||||||
|
import {
|
||||||
|
allColumns,
|
||||||
|
circuitOnlyColumns,
|
||||||
|
compareSortValues,
|
||||||
|
defaultVisibleColumnKeys,
|
||||||
|
deviceFieldKeys,
|
||||||
|
formatValue,
|
||||||
|
getBlockSortValue,
|
||||||
|
getCellKind,
|
||||||
|
getCircuitValue,
|
||||||
|
getDeviceValue,
|
||||||
|
normalizeFilterValue,
|
||||||
|
parseNumeric,
|
||||||
|
} from "../utils/circuit-grid-model";
|
||||||
|
import type {
|
||||||
|
CellKey,
|
||||||
|
CellKind,
|
||||||
|
ColumnDef,
|
||||||
|
RowType,
|
||||||
|
} from "../utils/circuit-grid-model";
|
||||||
import {
|
import {
|
||||||
createCircuit,
|
createCircuit,
|
||||||
createCircuitDeviceRow,
|
createCircuitDeviceRow,
|
||||||
@@ -38,42 +58,8 @@ import type {
|
|||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
type CellKey =
|
|
||||||
| "equipmentIdentifier"
|
|
||||||
| "displayName"
|
|
||||||
| "quantity"
|
|
||||||
| "powerPerUnit"
|
|
||||||
| "simultaneityFactor"
|
|
||||||
| "rowTotalPower"
|
|
||||||
| "circuitTotalPower"
|
|
||||||
| "protectionSummary"
|
|
||||||
| "cableSummary"
|
|
||||||
| "roomSummary"
|
|
||||||
| "remark"
|
|
||||||
| "technicalName"
|
|
||||||
| "connectionKind"
|
|
||||||
| "phaseType"
|
|
||||||
| "costGroup"
|
|
||||||
| "category"
|
|
||||||
| "level"
|
|
||||||
| "roomNumberSnapshot"
|
|
||||||
| "roomNameSnapshot"
|
|
||||||
| "cosPhi"
|
|
||||||
| "protectionType"
|
|
||||||
| "protectionRatedCurrent"
|
|
||||||
| "protectionCharacteristic"
|
|
||||||
| "cableType"
|
|
||||||
| "cableCrossSection"
|
|
||||||
| "cableLength"
|
|
||||||
| "rcdAssignment"
|
|
||||||
| "terminalDesignation"
|
|
||||||
| "status"
|
|
||||||
| "isReserve";
|
|
||||||
|
|
||||||
type SaveDirection = "stay" | "next" | "prev";
|
type SaveDirection = "stay" | "next" | "prev";
|
||||||
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
|
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
|
||||||
type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder";
|
|
||||||
type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
interface SelectedCell {
|
interface SelectedCell {
|
||||||
@@ -154,131 +140,6 @@ type CircuitReorderDropIntent =
|
|||||||
|
|
||||||
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
|
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
|
||||||
|
|
||||||
interface ColumnDef {
|
|
||||||
key: CellKey;
|
|
||||||
label: string;
|
|
||||||
numeric?: boolean;
|
|
||||||
defaultVisible?: boolean;
|
|
||||||
locked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Centralized column metadata keeps render, filtering/sorting, keyboard traversal,
|
|
||||||
// and persisted layout in sync. BMK/equipmentIdentifier stays locked as first column
|
|
||||||
// because circuit identity and circuit drag handles depend on always-visible BMK context.
|
|
||||||
const allColumns: ColumnDef[] = [
|
|
||||||
{ key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true },
|
|
||||||
{ key: "displayName", label: "Display name", defaultVisible: true },
|
|
||||||
{ key: "quantity", label: "Quantity", numeric: true, defaultVisible: true },
|
|
||||||
{ key: "powerPerUnit", label: "Power / unit", numeric: true, defaultVisible: true },
|
|
||||||
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true, defaultVisible: true },
|
|
||||||
{ key: "rowTotalPower", label: "Row total", numeric: true, defaultVisible: true },
|
|
||||||
{ key: "circuitTotalPower", label: "Circuit total", numeric: true, defaultVisible: true },
|
|
||||||
{ key: "protectionSummary", label: "Protection summary", defaultVisible: true },
|
|
||||||
{ key: "cableSummary", label: "Cable summary", defaultVisible: true },
|
|
||||||
{ key: "roomSummary", label: "Room", defaultVisible: true },
|
|
||||||
{ key: "remark", label: "Remark", defaultVisible: true },
|
|
||||||
{ key: "technicalName", label: "Technical name" },
|
|
||||||
{ key: "connectionKind", label: "Connection kind" },
|
|
||||||
{ key: "phaseType", label: "Phase type" },
|
|
||||||
{ key: "costGroup", label: "Cost group" },
|
|
||||||
{ key: "category", label: "Category" },
|
|
||||||
{ key: "level", label: "Level" },
|
|
||||||
{ key: "roomNumberSnapshot", label: "Room number" },
|
|
||||||
{ key: "roomNameSnapshot", label: "Room name" },
|
|
||||||
{ key: "cosPhi", label: "cosPhi", numeric: true },
|
|
||||||
{ key: "protectionType", label: "Protection type" },
|
|
||||||
{ key: "protectionRatedCurrent", label: "Protection rated current", numeric: true },
|
|
||||||
{ key: "protectionCharacteristic", label: "Protection characteristic" },
|
|
||||||
{ key: "cableType", label: "Cable type" },
|
|
||||||
{ key: "cableCrossSection", label: "Cable cross-section" },
|
|
||||||
{ key: "cableLength", label: "Cable length", numeric: true },
|
|
||||||
{ key: "rcdAssignment", label: "RCD assignment" },
|
|
||||||
{ key: "terminalDesignation", label: "Terminal designation" },
|
|
||||||
{ key: "status", label: "Status" },
|
|
||||||
{ key: "isReserve", label: "Reserve" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const defaultVisibleColumnKeys = allColumns.filter((column) => column.defaultVisible).map((column) => column.key);
|
|
||||||
|
|
||||||
const deviceOnlyColumns = new Set<CellKey>([
|
|
||||||
"quantity",
|
|
||||||
"powerPerUnit",
|
|
||||||
"simultaneityFactor",
|
|
||||||
"rowTotalPower",
|
|
||||||
"roomSummary",
|
|
||||||
"technicalName",
|
|
||||||
"connectionKind",
|
|
||||||
"phaseType",
|
|
||||||
"costGroup",
|
|
||||||
"category",
|
|
||||||
"level",
|
|
||||||
"roomNumberSnapshot",
|
|
||||||
"roomNameSnapshot",
|
|
||||||
"cosPhi",
|
|
||||||
]);
|
|
||||||
const circuitOnlyColumns = new Set<CellKey>([
|
|
||||||
"equipmentIdentifier",
|
|
||||||
"protectionSummary",
|
|
||||||
"cableSummary",
|
|
||||||
"circuitTotalPower",
|
|
||||||
"protectionType",
|
|
||||||
"protectionRatedCurrent",
|
|
||||||
"protectionCharacteristic",
|
|
||||||
"cableType",
|
|
||||||
"cableCrossSection",
|
|
||||||
"cableLength",
|
|
||||||
"rcdAssignment",
|
|
||||||
"terminalDesignation",
|
|
||||||
"status",
|
|
||||||
"isReserve",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const deviceFieldKeys = new Set<CellKey>([
|
|
||||||
"displayName",
|
|
||||||
"technicalName",
|
|
||||||
"connectionKind",
|
|
||||||
"phaseType",
|
|
||||||
"costGroup",
|
|
||||||
"category",
|
|
||||||
"level",
|
|
||||||
"roomSummary",
|
|
||||||
"roomNumberSnapshot",
|
|
||||||
"roomNameSnapshot",
|
|
||||||
"quantity",
|
|
||||||
"powerPerUnit",
|
|
||||||
"simultaneityFactor",
|
|
||||||
"cosPhi",
|
|
||||||
"remark",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const circuitFieldKeys = new Set<CellKey>([
|
|
||||||
"equipmentIdentifier",
|
|
||||||
"displayName",
|
|
||||||
"protectionType",
|
|
||||||
"protectionRatedCurrent",
|
|
||||||
"protectionCharacteristic",
|
|
||||||
"protectionSummary",
|
|
||||||
"cableType",
|
|
||||||
"cableCrossSection",
|
|
||||||
"cableLength",
|
|
||||||
"cableSummary",
|
|
||||||
"rcdAssignment",
|
|
||||||
"terminalDesignation",
|
|
||||||
"status",
|
|
||||||
"isReserve",
|
|
||||||
"remark",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function formatValue(value: string | number | boolean | undefined) {
|
|
||||||
if (value === undefined || value === null || value === "") {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
if (typeof value === "boolean") {
|
|
||||||
return value ? "Yes" : "No";
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUiError(err: unknown): string {
|
function normalizeUiError(err: unknown): string {
|
||||||
const message = err instanceof Error ? err.message : "Operation failed.";
|
const message = err instanceof Error ? err.message : "Operation failed.";
|
||||||
try {
|
try {
|
||||||
@@ -301,180 +162,6 @@ function normalizeUiError(err: unknown): string {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
|
||||||
const trimmed = draft.trim();
|
|
||||||
if (trimmed === "") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const parsed = Number(trimmed);
|
|
||||||
if (Number.isNaN(parsed)) {
|
|
||||||
throw new Error(`Invalid number in ${cellKey}`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): string | number | boolean | undefined {
|
|
||||||
switch (key) {
|
|
||||||
case "technicalName":
|
|
||||||
return device.name;
|
|
||||||
case "displayName":
|
|
||||||
return device.displayName || device.name;
|
|
||||||
case "phaseType":
|
|
||||||
return device.phaseType;
|
|
||||||
case "connectionKind":
|
|
||||||
return device.connectionKind;
|
|
||||||
case "costGroup":
|
|
||||||
return device.costGroup;
|
|
||||||
case "category":
|
|
||||||
return device.category;
|
|
||||||
case "level":
|
|
||||||
return device.level;
|
|
||||||
case "roomSummary":
|
|
||||||
return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
|
||||||
case "roomNumberSnapshot":
|
|
||||||
return device.roomNumberSnapshot;
|
|
||||||
case "roomNameSnapshot":
|
|
||||||
return device.roomNameSnapshot;
|
|
||||||
case "quantity":
|
|
||||||
return device.quantity;
|
|
||||||
case "powerPerUnit":
|
|
||||||
return device.powerPerUnit;
|
|
||||||
case "simultaneityFactor":
|
|
||||||
return device.simultaneityFactor;
|
|
||||||
case "cosPhi":
|
|
||||||
return device.cosPhi;
|
|
||||||
case "rowTotalPower":
|
|
||||||
return device.rowTotalPower;
|
|
||||||
case "remark":
|
|
||||||
return device.remark;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string | number | boolean | undefined {
|
|
||||||
switch (key) {
|
|
||||||
case "equipmentIdentifier":
|
|
||||||
return circuit.equipmentIdentifier;
|
|
||||||
case "displayName":
|
|
||||||
return circuit.displayName;
|
|
||||||
case "circuitTotalPower":
|
|
||||||
return circuit.circuitTotalPower;
|
|
||||||
case "protectionType":
|
|
||||||
return circuit.protectionType;
|
|
||||||
case "protectionRatedCurrent":
|
|
||||||
return circuit.protectionRatedCurrent;
|
|
||||||
case "protectionCharacteristic":
|
|
||||||
return circuit.protectionCharacteristic;
|
|
||||||
case "protectionSummary": {
|
|
||||||
const current =
|
|
||||||
circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
|
||||||
? `${circuit.protectionRatedCurrent}A`
|
|
||||||
: "";
|
|
||||||
return [circuit.protectionType, current, circuit.protectionCharacteristic]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.trim() || undefined;
|
|
||||||
}
|
|
||||||
case "cableSummary": {
|
|
||||||
const length =
|
|
||||||
circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
|
||||||
return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined;
|
|
||||||
}
|
|
||||||
case "cableType":
|
|
||||||
return circuit.cableType;
|
|
||||||
case "cableCrossSection":
|
|
||||||
return circuit.cableCrossSection;
|
|
||||||
case "cableLength":
|
|
||||||
return circuit.cableLength;
|
|
||||||
case "rcdAssignment":
|
|
||||||
return circuit.rcdAssignment;
|
|
||||||
case "terminalDesignation":
|
|
||||||
return circuit.terminalDesignation;
|
|
||||||
case "status":
|
|
||||||
return circuit.status;
|
|
||||||
case "isReserve":
|
|
||||||
return circuit.isReserve;
|
|
||||||
case "remark":
|
|
||||||
return circuit.remark;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFilterValue(value: string | number | boolean | undefined) {
|
|
||||||
return formatValue(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey) {
|
|
||||||
const firstRow = circuit.deviceRows[0];
|
|
||||||
if (circuitOnlyColumns.has(key)) {
|
|
||||||
return getCircuitValue(circuit, key);
|
|
||||||
}
|
|
||||||
if (deviceOnlyColumns.has(key)) {
|
|
||||||
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
||||||
}
|
|
||||||
if (key === "displayName" || key === "remark") {
|
|
||||||
if (circuit.displayName || circuit.remark) {
|
|
||||||
return getCircuitValue(circuit, key);
|
|
||||||
}
|
|
||||||
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
||||||
}
|
|
||||||
return getCircuitValue(circuit, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareSortValues(a: string | number | boolean | undefined, b: string | number | boolean | undefined) {
|
|
||||||
const av = a === undefined || a === null ? "" : a;
|
|
||||||
const bv = b === undefined || b === null ? "" : b;
|
|
||||||
if (typeof av === "number" && typeof bv === "number") {
|
|
||||||
return av - bv;
|
|
||||||
}
|
|
||||||
return String(av).localeCompare(String(bv), undefined, { sensitivity: "base", numeric: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
|
||||||
if (key === "rowTotalPower" || key === "circuitTotalPower") {
|
|
||||||
return "computed";
|
|
||||||
}
|
|
||||||
if (rowType === "section") {
|
|
||||||
return "readonly";
|
|
||||||
}
|
|
||||||
if (rowType === "placeholder") {
|
|
||||||
if (deviceFieldKeys.has(key)) {
|
|
||||||
return "deviceField";
|
|
||||||
}
|
|
||||||
if (circuitFieldKeys.has(key)) {
|
|
||||||
return "circuitField";
|
|
||||||
}
|
|
||||||
return "readonly";
|
|
||||||
}
|
|
||||||
if (rowType === "deviceRow") {
|
|
||||||
return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
|
|
||||||
}
|
|
||||||
if (rowType === "circuitSummary") {
|
|
||||||
return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
|
|
||||||
}
|
|
||||||
if (rowType === "reserveCircuit") {
|
|
||||||
if (deviceFieldKeys.has(key)) {
|
|
||||||
return "deviceField";
|
|
||||||
}
|
|
||||||
if (circuitFieldKeys.has(key)) {
|
|
||||||
return "circuitField";
|
|
||||||
}
|
|
||||||
return "readonly";
|
|
||||||
}
|
|
||||||
if (rowType === "circuitCompact") {
|
|
||||||
if (deviceFieldKeys.has(key)) {
|
|
||||||
return "deviceField";
|
|
||||||
}
|
|
||||||
if (circuitFieldKeys.has(key)) {
|
|
||||||
return "circuitField";
|
|
||||||
}
|
|
||||||
return "readonly";
|
|
||||||
}
|
|
||||||
return "readonly";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
||||||
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
|
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
||||||
|
|
||||||
|
export type CellKey =
|
||||||
|
| "equipmentIdentifier"
|
||||||
|
| "displayName"
|
||||||
|
| "quantity"
|
||||||
|
| "powerPerUnit"
|
||||||
|
| "simultaneityFactor"
|
||||||
|
| "rowTotalPower"
|
||||||
|
| "circuitTotalPower"
|
||||||
|
| "protectionSummary"
|
||||||
|
| "cableSummary"
|
||||||
|
| "roomSummary"
|
||||||
|
| "remark"
|
||||||
|
| "technicalName"
|
||||||
|
| "connectionKind"
|
||||||
|
| "phaseType"
|
||||||
|
| "costGroup"
|
||||||
|
| "category"
|
||||||
|
| "level"
|
||||||
|
| "roomNumberSnapshot"
|
||||||
|
| "roomNameSnapshot"
|
||||||
|
| "cosPhi"
|
||||||
|
| "protectionType"
|
||||||
|
| "protectionRatedCurrent"
|
||||||
|
| "protectionCharacteristic"
|
||||||
|
| "cableType"
|
||||||
|
| "cableCrossSection"
|
||||||
|
| "cableLength"
|
||||||
|
| "rcdAssignment"
|
||||||
|
| "terminalDesignation"
|
||||||
|
| "status"
|
||||||
|
| "isReserve";
|
||||||
|
|
||||||
|
export type RowType =
|
||||||
|
| "section"
|
||||||
|
| "circuitCompact"
|
||||||
|
| "circuitSummary"
|
||||||
|
| "deviceRow"
|
||||||
|
| "reserveCircuit"
|
||||||
|
| "placeholder";
|
||||||
|
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
||||||
|
export type GridValue = string | number | boolean | undefined;
|
||||||
|
|
||||||
|
export interface ColumnDef {
|
||||||
|
key: CellKey;
|
||||||
|
label: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
defaultVisible?: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BMK stays locked as the first column because circuit identity and circuit drag
|
||||||
|
// handles depend on an always-visible equipment identifier.
|
||||||
|
export const allColumns: ColumnDef[] = [
|
||||||
|
{ key: "equipmentIdentifier", label: "Equipment identifier", defaultVisible: true, locked: true },
|
||||||
|
{ key: "displayName", label: "Display name", defaultVisible: true },
|
||||||
|
{ key: "quantity", label: "Quantity", numeric: true, defaultVisible: true },
|
||||||
|
{ key: "powerPerUnit", label: "Power / unit", numeric: true, defaultVisible: true },
|
||||||
|
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true, defaultVisible: true },
|
||||||
|
{ key: "rowTotalPower", label: "Row total", numeric: true, defaultVisible: true },
|
||||||
|
{ key: "circuitTotalPower", label: "Circuit total", numeric: true, defaultVisible: true },
|
||||||
|
{ key: "protectionSummary", label: "Protection summary", defaultVisible: true },
|
||||||
|
{ key: "cableSummary", label: "Cable summary", defaultVisible: true },
|
||||||
|
{ key: "roomSummary", label: "Room", defaultVisible: true },
|
||||||
|
{ key: "remark", label: "Remark", defaultVisible: true },
|
||||||
|
{ key: "technicalName", label: "Technical name" },
|
||||||
|
{ key: "connectionKind", label: "Connection kind" },
|
||||||
|
{ key: "phaseType", label: "Phase type" },
|
||||||
|
{ key: "costGroup", label: "Cost group" },
|
||||||
|
{ key: "category", label: "Category" },
|
||||||
|
{ key: "level", label: "Level" },
|
||||||
|
{ key: "roomNumberSnapshot", label: "Room number" },
|
||||||
|
{ key: "roomNameSnapshot", label: "Room name" },
|
||||||
|
{ key: "cosPhi", label: "cosPhi", numeric: true },
|
||||||
|
{ key: "protectionType", label: "Protection type" },
|
||||||
|
{ key: "protectionRatedCurrent", label: "Protection rated current", numeric: true },
|
||||||
|
{ key: "protectionCharacteristic", label: "Protection characteristic" },
|
||||||
|
{ key: "cableType", label: "Cable type" },
|
||||||
|
{ key: "cableCrossSection", label: "Cable cross-section" },
|
||||||
|
{ key: "cableLength", label: "Cable length", numeric: true },
|
||||||
|
{ key: "rcdAssignment", label: "RCD assignment" },
|
||||||
|
{ key: "terminalDesignation", label: "Terminal designation" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "isReserve", label: "Reserve" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultVisibleColumnKeys = allColumns
|
||||||
|
.filter((column) => column.defaultVisible)
|
||||||
|
.map((column) => column.key);
|
||||||
|
|
||||||
|
const deviceOnlyColumns = new Set<CellKey>([
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"rowTotalPower",
|
||||||
|
"roomSummary",
|
||||||
|
"technicalName",
|
||||||
|
"connectionKind",
|
||||||
|
"phaseType",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"level",
|
||||||
|
"roomNumberSnapshot",
|
||||||
|
"roomNameSnapshot",
|
||||||
|
"cosPhi",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const circuitOnlyColumns = new Set<CellKey>([
|
||||||
|
"equipmentIdentifier",
|
||||||
|
"protectionSummary",
|
||||||
|
"cableSummary",
|
||||||
|
"circuitTotalPower",
|
||||||
|
"protectionType",
|
||||||
|
"protectionRatedCurrent",
|
||||||
|
"protectionCharacteristic",
|
||||||
|
"cableType",
|
||||||
|
"cableCrossSection",
|
||||||
|
"cableLength",
|
||||||
|
"rcdAssignment",
|
||||||
|
"terminalDesignation",
|
||||||
|
"status",
|
||||||
|
"isReserve",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const deviceFieldKeys = new Set<CellKey>([
|
||||||
|
"displayName",
|
||||||
|
"technicalName",
|
||||||
|
"connectionKind",
|
||||||
|
"phaseType",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"level",
|
||||||
|
"roomSummary",
|
||||||
|
"roomNumberSnapshot",
|
||||||
|
"roomNameSnapshot",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const circuitFieldKeys = new Set<CellKey>([
|
||||||
|
"equipmentIdentifier",
|
||||||
|
"displayName",
|
||||||
|
"protectionType",
|
||||||
|
"protectionRatedCurrent",
|
||||||
|
"protectionCharacteristic",
|
||||||
|
"protectionSummary",
|
||||||
|
"cableType",
|
||||||
|
"cableCrossSection",
|
||||||
|
"cableLength",
|
||||||
|
"cableSummary",
|
||||||
|
"rcdAssignment",
|
||||||
|
"terminalDesignation",
|
||||||
|
"status",
|
||||||
|
"isReserve",
|
||||||
|
"remark",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function formatValue(value: GridValue): string {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "Yes" : "No";
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
throw new Error(`Invalid number in ${cellKey}`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
|
||||||
|
switch (key) {
|
||||||
|
case "technicalName": return device.name;
|
||||||
|
case "displayName": return device.displayName || device.name;
|
||||||
|
case "phaseType": return device.phaseType;
|
||||||
|
case "connectionKind": return device.connectionKind;
|
||||||
|
case "costGroup": return device.costGroup;
|
||||||
|
case "category": return device.category;
|
||||||
|
case "level": return device.level;
|
||||||
|
case "roomSummary": return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
||||||
|
case "roomNumberSnapshot": return device.roomNumberSnapshot;
|
||||||
|
case "roomNameSnapshot": return device.roomNameSnapshot;
|
||||||
|
case "quantity": return device.quantity;
|
||||||
|
case "powerPerUnit": return device.powerPerUnit;
|
||||||
|
case "simultaneityFactor": return device.simultaneityFactor;
|
||||||
|
case "cosPhi": return device.cosPhi;
|
||||||
|
case "rowTotalPower": return device.rowTotalPower;
|
||||||
|
case "remark": return device.remark;
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
||||||
|
switch (key) {
|
||||||
|
case "equipmentIdentifier": return circuit.equipmentIdentifier;
|
||||||
|
case "displayName": return circuit.displayName;
|
||||||
|
case "circuitTotalPower": return circuit.circuitTotalPower;
|
||||||
|
case "protectionType": return circuit.protectionType;
|
||||||
|
case "protectionRatedCurrent": return circuit.protectionRatedCurrent;
|
||||||
|
case "protectionCharacteristic": return circuit.protectionCharacteristic;
|
||||||
|
case "protectionSummary": {
|
||||||
|
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
||||||
|
? `${circuit.protectionRatedCurrent}A`
|
||||||
|
: "";
|
||||||
|
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined;
|
||||||
|
}
|
||||||
|
case "cableSummary": {
|
||||||
|
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
||||||
|
return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined;
|
||||||
|
}
|
||||||
|
case "cableType": return circuit.cableType;
|
||||||
|
case "cableCrossSection": return circuit.cableCrossSection;
|
||||||
|
case "cableLength": return circuit.cableLength;
|
||||||
|
case "rcdAssignment": return circuit.rcdAssignment;
|
||||||
|
case "terminalDesignation": return circuit.terminalDesignation;
|
||||||
|
case "status": return circuit.status;
|
||||||
|
case "isReserve": return circuit.isReserve;
|
||||||
|
case "remark": return circuit.remark;
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFilterValue(value: GridValue): string {
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
||||||
|
const firstRow = circuit.deviceRows[0];
|
||||||
|
if (circuitOnlyColumns.has(key)) {
|
||||||
|
return getCircuitValue(circuit, key);
|
||||||
|
}
|
||||||
|
if (deviceOnlyColumns.has(key)) {
|
||||||
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
||||||
|
}
|
||||||
|
if (key === "displayName") {
|
||||||
|
if (circuit.displayName) return circuit.displayName;
|
||||||
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
||||||
|
}
|
||||||
|
if (key === "remark") {
|
||||||
|
if (circuit.remark) return circuit.remark;
|
||||||
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
||||||
|
}
|
||||||
|
return getCircuitValue(circuit, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareSortValues(left: GridValue, right: GridValue): number {
|
||||||
|
const leftValue = left === undefined || left === null ? "" : left;
|
||||||
|
const rightValue = right === undefined || right === null ? "" : right;
|
||||||
|
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
||||||
|
return leftValue - rightValue;
|
||||||
|
}
|
||||||
|
return String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: "base", numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||||
|
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
|
||||||
|
if (rowType === "section") return "readonly";
|
||||||
|
if (rowType === "placeholder") {
|
||||||
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
||||||
|
if (circuitFieldKeys.has(key)) return "circuitField";
|
||||||
|
return "readonly";
|
||||||
|
}
|
||||||
|
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
|
||||||
|
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
|
||||||
|
if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
|
||||||
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
||||||
|
if (circuitFieldKeys.has(key)) return "circuitField";
|
||||||
|
}
|
||||||
|
return "readonly";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
allColumns,
|
||||||
|
getBlockSortValue,
|
||||||
|
getCellKind,
|
||||||
|
getCircuitValue,
|
||||||
|
getDeviceValue,
|
||||||
|
parseNumeric,
|
||||||
|
} from "../src/frontend/utils/circuit-grid-model.js";
|
||||||
|
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
|
||||||
|
|
||||||
|
const device: CircuitTreeDeviceRowDto = {
|
||||||
|
id: "row-1",
|
||||||
|
sortOrder: 10,
|
||||||
|
name: "Technical light",
|
||||||
|
displayName: "Office light",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
roomNumberSnapshot: "1.01",
|
||||||
|
roomNameSnapshot: "Office",
|
||||||
|
quantity: 2,
|
||||||
|
powerPerUnit: 0.05,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
rowTotalPower: 0.08,
|
||||||
|
remark: "Device remark",
|
||||||
|
};
|
||||||
|
|
||||||
|
const circuit: CircuitTreeCircuitDto = {
|
||||||
|
id: "circuit-1",
|
||||||
|
circuitListId: "list-1",
|
||||||
|
sectionId: "section-1",
|
||||||
|
equipmentIdentifier: "-1F1",
|
||||||
|
displayName: "Lighting circuit",
|
||||||
|
sortOrder: 10,
|
||||||
|
protectionType: "MCB",
|
||||||
|
protectionRatedCurrent: 16,
|
||||||
|
protectionCharacteristic: "B",
|
||||||
|
cableType: "NYM-J",
|
||||||
|
cableCrossSection: "1.5 mm²",
|
||||||
|
cableLength: 20,
|
||||||
|
isReserve: false,
|
||||||
|
remark: "Circuit remark",
|
||||||
|
circuitTotalPower: 0.08,
|
||||||
|
deviceRows: [device],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("circuit grid model", () => {
|
||||||
|
it("keeps the equipment identifier locked as the first column", () => {
|
||||||
|
assert.equal(allColumns[0].key, "equipmentIdentifier");
|
||||||
|
assert.equal(allColumns[0].locked, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps shared fields to the correct level for every row shape", () => {
|
||||||
|
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
|
||||||
|
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
|
||||||
|
assert.equal(getCellKind("circuitSummary", "displayName"), "circuitField");
|
||||||
|
assert.equal(getCellKind("deviceRow", "displayName"), "deviceField");
|
||||||
|
assert.equal(getCellKind("reserveCircuit", "remark"), "deviceField");
|
||||||
|
assert.equal(getCellKind("placeholder", "displayName"), "deviceField");
|
||||||
|
assert.equal(getCellKind("deviceRow", "equipmentIdentifier"), "readonly");
|
||||||
|
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects circuit and device values without mixing ownership", () => {
|
||||||
|
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
|
||||||
|
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
|
||||||
|
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
|
||||||
|
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses circuit display values for block sorting and falls back to the first device", () => {
|
||||||
|
assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit");
|
||||||
|
assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light");
|
||||||
|
assert.equal(getBlockSortValue(circuit, "quantity"), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses numeric drafts and rejects invalid values", () => {
|
||||||
|
assert.equal(parseNumeric("quantity", " 2.5 "), 2.5);
|
||||||
|
assert.equal(parseNumeric("quantity", ""), undefined);
|
||||||
|
assert.throws(() => parseNumeric("quantity", "two"), /Invalid number/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user