Extract circuit grid model

This commit is contained in:
2026-07-22 20:40:07 +02:00
parent b3a7ff79da
commit 009152b406
5 changed files with 396 additions and 335 deletions
+20 -333
View File
@@ -15,6 +15,26 @@ import {
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent,
} 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 {
createCircuit,
createCircuitDeviceRow,
@@ -38,42 +58,8 @@ import type {
ProjectDeviceDto,
} 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 StartEditMode = "selectExisting" | "replaceWithTypedChar";
type RowType = "section" | "circuitCompact" | "circuitSummary" | "deviceRow" | "reserveCircuit" | "placeholder";
type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
type SortDirection = "asc" | "desc";
interface SelectedCell {
@@ -154,131 +140,6 @@ type CircuitReorderDropIntent =
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 {
const message = err instanceof Error ? err.message : "Operation failed.";
try {
@@ -301,180 +162,6 @@ function normalizeUiError(err: unknown): string {
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>) {
return event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;
}
+283
View File
@@ -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";
}