New usable editor
This commit is contained in:
@@ -39,3 +39,121 @@ body {
|
|||||||
.circuit-tree-table .indented-cell {
|
.circuit-tree-table .indented-cell {
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-editor-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #d9dee8;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid th,
|
||||||
|
.tree-grid td {
|
||||||
|
border: 1px solid #e4e9f2;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid th {
|
||||||
|
background: #f4f7fb;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-row td {
|
||||||
|
background: #e8eef8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .summary-row td {
|
||||||
|
background: #f3f7fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .device-row td:first-child {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .reserve-row td {
|
||||||
|
background: #fff8e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .placeholder-row td {
|
||||||
|
background: #f7f7f7;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .cell-editable {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .cell-selected {
|
||||||
|
outline: 2px solid #4c7dd9;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 5rem;
|
||||||
|
border: 1px solid #9fb6e0;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .action-cell {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .action-cell button {
|
||||||
|
border: 1px solid #c4cddc;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.info {
|
||||||
|
background: #ebf3ff;
|
||||||
|
border-color: #bad1f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.error {
|
||||||
|
background: #fdecec;
|
||||||
|
border-color: #f5b5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice.muted {
|
||||||
|
background: #f6f6f6;
|
||||||
|
border-color: #e4e4e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-hint {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
|
||||||
|
|
||||||
|
export default function CircuitTreeEditPage() {
|
||||||
|
const params = useParams<{ projectId: string; circuitListId: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-4">
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="h4 mb-1">Circuit Tree Editor</h1>
|
||||||
|
<p className="text-secondary mb-0">Basic editing for the circuit-first model.</p>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/projects/${params.projectId}/circuit-lists/${params.circuitListId}/tree`}
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
>
|
||||||
|
Read-only tree
|
||||||
|
</Link>
|
||||||
|
<Link href={`/projects/${params.projectId}/circuit-lists`} className="btn btn-outline-secondary btn-sm">
|
||||||
|
Legacy editor
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CircuitTreeEditor projectId={params.projectId} circuitListId={params.circuitListId} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,12 @@ export default function CircuitTreePreviewPage() {
|
|||||||
Read-only preview of section blocks, circuits and device rows.
|
Read-only preview of section blocks, circuits and device rows.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${projectId}/circuit-lists/${circuitListId}/tree-edit`}
|
||||||
|
className="btn btn-outline-primary btn-sm"
|
||||||
|
>
|
||||||
|
Open editor
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/projects/${projectId}/circuit-lists`}
|
href={`/projects/${projectId}/circuit-lists`}
|
||||||
className="btn btn-outline-secondary btn-sm"
|
className="btn btn-outline-secondary btn-sm"
|
||||||
@@ -30,4 +36,3 @@ export default function CircuitTreePreviewPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,744 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
createCircuit,
|
||||||
|
createCircuitDeviceRow,
|
||||||
|
deleteCircuitById,
|
||||||
|
deleteCircuitDeviceRowById,
|
||||||
|
getCircuitTree,
|
||||||
|
getNextCircuitIdentifier,
|
||||||
|
renumberCircuitSection,
|
||||||
|
updateCircuitById,
|
||||||
|
updateCircuitDeviceRowById,
|
||||||
|
} from "../utils/api";
|
||||||
|
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto, CircuitTreeResponseDto } from "../types";
|
||||||
|
|
||||||
|
type CellKey =
|
||||||
|
| "equipmentIdentifier"
|
||||||
|
| "name"
|
||||||
|
| "displayName"
|
||||||
|
| "phaseType"
|
||||||
|
| "connectionKind"
|
||||||
|
| "costGroup"
|
||||||
|
| "category"
|
||||||
|
| "level"
|
||||||
|
| "roomNumberSnapshot"
|
||||||
|
| "roomNameSnapshot"
|
||||||
|
| "quantity"
|
||||||
|
| "powerPerUnit"
|
||||||
|
| "simultaneityFactor"
|
||||||
|
| "cosPhi"
|
||||||
|
| "rowTotalPower"
|
||||||
|
| "circuitTotalPower"
|
||||||
|
| "protectionType"
|
||||||
|
| "protectionRatedCurrent"
|
||||||
|
| "protectionCharacteristic"
|
||||||
|
| "cableType"
|
||||||
|
| "cableCrossSection"
|
||||||
|
| "cableLength"
|
||||||
|
| "rcdAssignment"
|
||||||
|
| "terminalDesignation"
|
||||||
|
| "voltage"
|
||||||
|
| "status"
|
||||||
|
| "isReserve"
|
||||||
|
| "remark";
|
||||||
|
|
||||||
|
interface GridRow {
|
||||||
|
rowKey: string;
|
||||||
|
kind: "section" | "summary" | "compact" | "device" | "reserve" | "placeholder";
|
||||||
|
sectionId: string;
|
||||||
|
circuit?: CircuitTreeCircuitDto;
|
||||||
|
device?: CircuitTreeDeviceRowDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedCell {
|
||||||
|
rowKey: string;
|
||||||
|
cellKey: CellKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditingCell extends SelectedCell {
|
||||||
|
draft: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
|
||||||
|
{ key: "equipmentIdentifier", label: "Equipment identifier" },
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "displayName", label: "Display name" },
|
||||||
|
{ key: "phaseType", label: "Phase type" },
|
||||||
|
{ key: "connectionKind", label: "Connection kind" },
|
||||||
|
{ key: "costGroup", label: "Cost group" },
|
||||||
|
{ key: "category", label: "Category" },
|
||||||
|
{ key: "level", label: "Level" },
|
||||||
|
{ key: "roomNumberSnapshot", label: "Room number" },
|
||||||
|
{ key: "roomNameSnapshot", label: "Room name" },
|
||||||
|
{ key: "quantity", label: "Quantity", numeric: true },
|
||||||
|
{ key: "powerPerUnit", label: "Power / unit", numeric: true },
|
||||||
|
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true },
|
||||||
|
{ key: "cosPhi", label: "cosPhi", numeric: true },
|
||||||
|
{ key: "rowTotalPower", label: "Row total", numeric: true },
|
||||||
|
{ key: "circuitTotalPower", label: "Circuit total", numeric: true },
|
||||||
|
{ key: "protectionType", label: "Protection type" },
|
||||||
|
{ key: "protectionRatedCurrent", label: "Protection 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: "voltage", label: "Voltage", numeric: true },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "isReserve", label: "Reserve" },
|
||||||
|
{ key: "remark", label: "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 getDeviceFieldValue(device: CircuitTreeDeviceRowDto, cellKey: CellKey): string | number | boolean | undefined {
|
||||||
|
switch (cellKey) {
|
||||||
|
case "name":
|
||||||
|
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 "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 getCircuitFieldValue(circuit: CircuitTreeCircuitDto, cellKey: CellKey): string | number | boolean | undefined {
|
||||||
|
switch (cellKey) {
|
||||||
|
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 "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 "voltage":
|
||||||
|
return circuit.voltage;
|
||||||
|
case "status":
|
||||||
|
return circuit.status;
|
||||||
|
case "isReserve":
|
||||||
|
return circuit.isReserve;
|
||||||
|
case "remark":
|
||||||
|
return circuit.remark;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCircuitField(cellKey: CellKey) {
|
||||||
|
return [
|
||||||
|
"equipmentIdentifier",
|
||||||
|
"displayName",
|
||||||
|
"protectionType",
|
||||||
|
"protectionRatedCurrent",
|
||||||
|
"protectionCharacteristic",
|
||||||
|
"cableType",
|
||||||
|
"cableCrossSection",
|
||||||
|
"cableLength",
|
||||||
|
"rcdAssignment",
|
||||||
|
"terminalDesignation",
|
||||||
|
"voltage",
|
||||||
|
"status",
|
||||||
|
"isReserve",
|
||||||
|
"remark",
|
||||||
|
"circuitTotalPower",
|
||||||
|
].includes(cellKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeviceField(cellKey: CellKey) {
|
||||||
|
return [
|
||||||
|
"name",
|
||||||
|
"displayName",
|
||||||
|
"phaseType",
|
||||||
|
"connectionKind",
|
||||||
|
"costGroup",
|
||||||
|
"category",
|
||||||
|
"level",
|
||||||
|
"roomNumberSnapshot",
|
||||||
|
"roomNameSnapshot",
|
||||||
|
"quantity",
|
||||||
|
"powerPerUnit",
|
||||||
|
"simultaneityFactor",
|
||||||
|
"cosPhi",
|
||||||
|
"remark",
|
||||||
|
"rowTotalPower",
|
||||||
|
].includes(cellKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 CircuitTreeEditor(props: { projectId: string; circuitListId: string }) {
|
||||||
|
const { projectId, circuitListId } = props;
|
||||||
|
const [data, setData] = useState<CircuitTreeResponseDto | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
||||||
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
|
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const tree = await getCircuitTree(projectId, circuitListId);
|
||||||
|
setData(tree);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Could not load circuit tree.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTree();
|
||||||
|
}, [projectId, circuitListId]);
|
||||||
|
|
||||||
|
const gridRows = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [] as GridRow[];
|
||||||
|
}
|
||||||
|
const rows: GridRow[] = [];
|
||||||
|
for (const section of data.sections) {
|
||||||
|
rows.push({ rowKey: `section:${section.id}`, kind: "section", sectionId: section.id });
|
||||||
|
for (const circuit of section.circuits) {
|
||||||
|
if (circuit.deviceRows.length === 0) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `reserve:${circuit.id}`,
|
||||||
|
kind: "reserve",
|
||||||
|
sectionId: section.id,
|
||||||
|
circuit,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (circuit.deviceRows.length === 1) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `compact:${circuit.id}`,
|
||||||
|
kind: "compact",
|
||||||
|
sectionId: section.id,
|
||||||
|
circuit,
|
||||||
|
device: circuit.deviceRows[0],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
rowKey: `summary:${circuit.id}`,
|
||||||
|
kind: "summary",
|
||||||
|
sectionId: section.id,
|
||||||
|
circuit,
|
||||||
|
});
|
||||||
|
for (const device of circuit.deviceRows) {
|
||||||
|
rows.push({
|
||||||
|
rowKey: `device:${device.id}`,
|
||||||
|
kind: "device",
|
||||||
|
sectionId: section.id,
|
||||||
|
circuit,
|
||||||
|
device,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.push({ rowKey: `placeholder:${section.id}`, kind: "placeholder", sectionId: section.id });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingFocus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedCell(pendingFocus);
|
||||||
|
setEditingCell({ ...pendingFocus, draft: "" });
|
||||||
|
setPendingFocus(null);
|
||||||
|
}, [pendingFocus]);
|
||||||
|
|
||||||
|
const editableCells = useMemo(() => {
|
||||||
|
const cells: SelectedCell[] = [];
|
||||||
|
for (const row of gridRows) {
|
||||||
|
for (const column of columns) {
|
||||||
|
if (canEditCell(row, column.key)) {
|
||||||
|
cells.push({ rowKey: row.rowKey, cellKey: column.key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}, [gridRows]);
|
||||||
|
|
||||||
|
function findRow(rowKey: string) {
|
||||||
|
return gridRows.find((row) => row.rowKey === rowKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditCell(row: GridRow, cellKey: CellKey) {
|
||||||
|
if (row.kind === "section" || row.kind === "placeholder") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (cellKey === "rowTotalPower" || cellKey === "circuitTotalPower") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.kind === "summary" || row.kind === "reserve") {
|
||||||
|
return isCircuitField(cellKey);
|
||||||
|
}
|
||||||
|
if (row.kind === "device") {
|
||||||
|
return isDeviceField(cellKey);
|
||||||
|
}
|
||||||
|
if (row.kind === "compact") {
|
||||||
|
return isCircuitField(cellKey) || isDeviceField(cellKey);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellValue(row: GridRow, cellKey: CellKey): string | number | boolean | undefined {
|
||||||
|
const circuit = row.circuit;
|
||||||
|
const device = row.device;
|
||||||
|
if (!circuit) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (row.kind === "compact" && device) {
|
||||||
|
if (cellKey === "displayName") {
|
||||||
|
return device.displayName || device.name;
|
||||||
|
}
|
||||||
|
if (isDeviceField(cellKey)) {
|
||||||
|
return getDeviceFieldValue(device, cellKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.kind === "device" && device) {
|
||||||
|
return getDeviceFieldValue(device, cellKey);
|
||||||
|
}
|
||||||
|
return getCircuitFieldValue(circuit, cellKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(cell: SelectedCell, initialDraft?: string) {
|
||||||
|
const row = findRow(cell.rowKey);
|
||||||
|
if (!row || !canEditCell(row, cell.cellKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = initialDraft ?? String(getCellValue(row, cell.cellKey) ?? "");
|
||||||
|
setEditingCell({ ...cell, draft: value === "-" ? "" : value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelection(offset: number) {
|
||||||
|
if (!selectedCell) {
|
||||||
|
if (editableCells.length > 0) {
|
||||||
|
setSelectedCell(editableCells[0]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = editableCells.findIndex(
|
||||||
|
(cell) => cell.rowKey === selectedCell.rowKey && cell.cellKey === selectedCell.cellKey
|
||||||
|
);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = Math.min(editableCells.length - 1, Math.max(0, index + offset));
|
||||||
|
setSelectedCell(editableCells[nextIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditingCell(nextCell?: SelectedCell | null) {
|
||||||
|
if (!editingCell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = findRow(editingCell.rowKey);
|
||||||
|
if (!row || !row.circuit) {
|
||||||
|
setEditingCell(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
const key = editingCell.cellKey;
|
||||||
|
const draft = editingCell.draft;
|
||||||
|
|
||||||
|
if ((row.kind === "summary" || row.kind === "reserve") && isCircuitField(key)) {
|
||||||
|
await patchCircuit(row.circuit.id, key, draft);
|
||||||
|
} else if (row.kind === "device" && row.device && isDeviceField(key)) {
|
||||||
|
await patchDeviceRow(row.device.id, key, draft);
|
||||||
|
} else if (row.kind === "compact") {
|
||||||
|
if (row.device && isDeviceField(key)) {
|
||||||
|
await patchDeviceRow(row.device.id, key, draft);
|
||||||
|
} else if (isCircuitField(key)) {
|
||||||
|
await patchCircuit(row.circuit.id, key, draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingCell(null);
|
||||||
|
await loadTree();
|
||||||
|
if (nextCell) {
|
||||||
|
setSelectedCell(nextCell);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Save failed.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchCircuit(circuitId: string, key: CellKey, draft: string) {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (key === "isReserve") {
|
||||||
|
payload.isReserve = draft.toLowerCase() === "true" || draft === "1" || draft.toLowerCase() === "yes";
|
||||||
|
} else if (["protectionRatedCurrent", "cableLength", "voltage"].includes(key)) {
|
||||||
|
payload[key] = parseNumeric(key, draft);
|
||||||
|
} else if (key === "circuitTotalPower" || key === "rowTotalPower") {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
payload[key] = draft.trim() === "" ? undefined : draft;
|
||||||
|
}
|
||||||
|
await updateCircuitById(circuitId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
||||||
|
const payload: Record<string, unknown> = {};
|
||||||
|
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
|
||||||
|
payload[key] = parseNumeric(key, draft);
|
||||||
|
} else {
|
||||||
|
payload[key] = draft.trim() === "" ? undefined : draft;
|
||||||
|
}
|
||||||
|
await updateCircuitDeviceRowById(rowId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddReserveCircuit(sectionId: string) {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
const section = data?.sections.find((entry) => entry.id === sectionId);
|
||||||
|
if (!section) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = await getNextCircuitIdentifier(sectionId);
|
||||||
|
const sortOrder =
|
||||||
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||||
|
await createCircuit(projectId, circuitListId, {
|
||||||
|
sectionId,
|
||||||
|
equipmentIdentifier: next.nextIdentifier,
|
||||||
|
displayName: "Reserve",
|
||||||
|
sortOrder,
|
||||||
|
isReserve: true,
|
||||||
|
});
|
||||||
|
await loadTree();
|
||||||
|
setActiveSectionId(sectionId);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to add reserve circuit.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
const created = (await createCircuitDeviceRow(circuit.id, {
|
||||||
|
name: "Manual device",
|
||||||
|
displayName: "Manual device",
|
||||||
|
phaseType: "single_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 0,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 1,
|
||||||
|
})) as { id: string };
|
||||||
|
await loadTree();
|
||||||
|
setActiveSectionId(sectionId);
|
||||||
|
setPendingFocus({
|
||||||
|
rowKey: circuit.deviceRows.length === 0 ? `compact:${circuit.id}` : `device:${created.id}`,
|
||||||
|
cellKey: "displayName",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to add device row.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteDevice(rowId: string) {
|
||||||
|
if (!confirm("Delete this device row?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
await deleteCircuitDeviceRowById(rowId);
|
||||||
|
await loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete device row.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteCircuit(circuitId: string) {
|
||||||
|
if (!confirm("Delete this circuit and all assigned device rows?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
await deleteCircuitById(circuitId);
|
||||||
|
await loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to delete circuit.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenumberSection(sectionId: string) {
|
||||||
|
if (!confirm("Renumber this section? Only circuits in this section will change.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
await renumberCircuitSection(sectionId);
|
||||||
|
await loadTree();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to renumber section.");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (editingCell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isCtrlPlus =
|
||||||
|
event.ctrlKey &&
|
||||||
|
(event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd");
|
||||||
|
if (isCtrlPlus) {
|
||||||
|
event.preventDefault();
|
||||||
|
const sectionId = activeSectionId ?? data?.sections[0]?.id;
|
||||||
|
if (sectionId) {
|
||||||
|
void handleAddReserveCircuit(sectionId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(1);
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(-1);
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(1);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
moveSelection(-1);
|
||||||
|
} else if (event.key === "Enter" || event.key === "F2") {
|
||||||
|
event.preventDefault();
|
||||||
|
if (selectedCell) {
|
||||||
|
startEdit(selectedCell);
|
||||||
|
}
|
||||||
|
} else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||||
|
if (selectedCell) {
|
||||||
|
event.preventDefault();
|
||||||
|
startEdit(selectedCell, event.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="notice info">Loading circuit tree editor...</div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="notice error">{error}</div>;
|
||||||
|
}
|
||||||
|
if (!data || data.sections.length === 0) {
|
||||||
|
return <div className="notice muted">No sections/circuits available.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tree-editor-shell">
|
||||||
|
{isSaving ? <div className="notice info">Saving...</div> : null}
|
||||||
|
<div className="tree-grid-wrap" ref={containerRef} tabIndex={0} onKeyDown={handleKeyDown}>
|
||||||
|
<table className="tree-grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column.key} className={column.numeric ? "num" : ""}>
|
||||||
|
{column.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{gridRows.map((row) => {
|
||||||
|
if (row.kind === "section") {
|
||||||
|
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
|
||||||
|
return (
|
||||||
|
<tr key={row.rowKey} className="section-row">
|
||||||
|
<td colSpan={columns.length}>
|
||||||
|
<strong>{section.displayName}</strong>
|
||||||
|
</td>
|
||||||
|
<td className="action-cell">
|
||||||
|
<button type="button" onClick={() => void handleAddReserveCircuit(section.id)}>
|
||||||
|
Add reserve circuit
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleRenumberSection(section.id)}>
|
||||||
|
Renumber section
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.kind === "placeholder") {
|
||||||
|
return (
|
||||||
|
<tr key={row.rowKey} className="placeholder-row">
|
||||||
|
<td>-frei-</td>
|
||||||
|
<td colSpan={columns.length}>read-only placeholder</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.rowKey}
|
||||||
|
className={row.kind === "summary" ? "summary-row" : row.kind === "device" ? "device-row" : row.kind === "reserve" ? "reserve-row" : ""}
|
||||||
|
onClick={() => setActiveSectionId(row.sectionId)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const selected =
|
||||||
|
selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;
|
||||||
|
const editing =
|
||||||
|
editingCell?.rowKey === row.rowKey && editingCell.cellKey === column.key;
|
||||||
|
const editable = canEditCell(row, column.key);
|
||||||
|
const value = getCellValue(row, column.key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.key}
|
||||||
|
className={`${column.numeric ? "num" : ""} ${selected ? "cell-selected" : ""} ${editable ? "cell-editable" : ""}`}
|
||||||
|
onClick={() => setSelectedCell({ rowKey: row.rowKey, cellKey: column.key })}
|
||||||
|
onDoubleClick={() => startEdit({ rowKey: row.rowKey, cellKey: column.key })}
|
||||||
|
>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editingCell.draft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setEditingCell((current) =>
|
||||||
|
current ? { ...current, draft: event.target.value } : current
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
void saveEditingCell(selectedCell);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
setEditingCell(null);
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
event.preventDefault();
|
||||||
|
const idx = editableCells.findIndex(
|
||||||
|
(cell) =>
|
||||||
|
cell.rowKey === editingCell.rowKey && cell.cellKey === editingCell.cellKey
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
idx >= 0
|
||||||
|
? editableCells[
|
||||||
|
event.shiftKey
|
||||||
|
? Math.max(0, idx - 1)
|
||||||
|
: Math.min(editableCells.length - 1, idx + 1)
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
void saveEditingCell(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
formatValue(value)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="action-cell">
|
||||||
|
{row.circuit && row.kind !== "device" ? (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => void handleAddManualDevice(row.circuit!, row.sectionId)}>
|
||||||
|
Add manual device
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleDeleteCircuit(row.circuit!.id)}>
|
||||||
|
Delete circuit
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{row.device ? (
|
||||||
|
<button type="button" onClick={() => void handleDeleteDevice(row.device!.id)}>
|
||||||
|
Delete device
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds reserve circuit at end of active section.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -241,3 +241,47 @@ export interface CircuitTreeResponseDto {
|
|||||||
sections: CircuitTreeSectionDto[];
|
sections: CircuitTreeSectionDto[];
|
||||||
migrationReport?: CircuitTreeMigrationReportDto;
|
migrationReport?: CircuitTreeMigrationReportDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCircuitInputDto {
|
||||||
|
sectionId: string;
|
||||||
|
equipmentIdentifier: string;
|
||||||
|
displayName?: string;
|
||||||
|
sortOrder: number;
|
||||||
|
protectionType?: string;
|
||||||
|
protectionRatedCurrent?: number;
|
||||||
|
protectionCharacteristic?: string;
|
||||||
|
cableType?: string;
|
||||||
|
cableCrossSection?: string;
|
||||||
|
cableLength?: number;
|
||||||
|
rcdAssignment?: string;
|
||||||
|
terminalDesignation?: string;
|
||||||
|
voltage?: number;
|
||||||
|
status?: string;
|
||||||
|
isReserve?: boolean;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCircuitInputDto = Partial<CreateCircuitInputDto>;
|
||||||
|
|
||||||
|
export interface CreateCircuitDeviceRowInputDto {
|
||||||
|
linkedProjectDeviceId?: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
phaseType?: string;
|
||||||
|
connectionKind?: string;
|
||||||
|
costGroup?: string;
|
||||||
|
category?: string;
|
||||||
|
level?: string;
|
||||||
|
roomId?: string;
|
||||||
|
roomNumberSnapshot?: string;
|
||||||
|
roomNameSnapshot?: string;
|
||||||
|
quantity: number;
|
||||||
|
powerPerUnit: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi?: number;
|
||||||
|
remark?: string;
|
||||||
|
overriddenFields?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCircuitDeviceRowInputDto = Partial<CreateCircuitDeviceRowInputDto>;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import type {
|
|||||||
RoomDto,
|
RoomDto,
|
||||||
UpdateConsumerInput,
|
UpdateConsumerInput,
|
||||||
CircuitTreeResponseDto,
|
CircuitTreeResponseDto,
|
||||||
|
CreateCircuitInputDto,
|
||||||
|
UpdateCircuitInputDto,
|
||||||
|
CreateCircuitDeviceRowInputDto,
|
||||||
|
UpdateCircuitDeviceRowInputDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
@@ -82,6 +86,58 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
|
|||||||
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCircuit(projectId: string, circuitListId: string, input: CreateCircuitInputDto) {
|
||||||
|
return request(`/api/projects/${projectId}/circuit-lists/${circuitListId}/circuits`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCircuitById(circuitId: string, input: UpdateCircuitInputDto) {
|
||||||
|
return request(`/api/circuits/${circuitId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCircuitById(circuitId: string) {
|
||||||
|
return request<void>(`/api/circuits/${circuitId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextCircuitIdentifier(sectionId: string) {
|
||||||
|
return request<{ sectionId: string; nextIdentifier: string }>(
|
||||||
|
`/api/circuit-sections/${sectionId}/next-identifier`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCircuitDeviceRow(circuitId: string, input: CreateCircuitDeviceRowInputDto) {
|
||||||
|
return request(`/api/circuits/${circuitId}/device-rows`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCircuitDeviceRowById(rowId: string, input: UpdateCircuitDeviceRowInputDto) {
|
||||||
|
return request(`/api/circuit-device-rows/${rowId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCircuitDeviceRowById(rowId: string) {
|
||||||
|
return request<void>(`/api/circuit-device-rows/${rowId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renumberCircuitSection(sectionId: string) {
|
||||||
|
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listFloors(projectId: string) {
|
export function listFloors(projectId: string) {
|
||||||
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user