New usable editor
This commit is contained in:
@@ -39,3 +39,121 @@ body {
|
||||
.circuit-tree-table .indented-cell {
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/projects/${projectId}/circuit-lists/${circuitListId}/tree-edit`}
|
||||
className="btn btn-outline-primary btn-sm"
|
||||
>
|
||||
Open editor
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/circuit-lists`}
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
@@ -30,4 +36,3 @@ export default function CircuitTreePreviewPage() {
|
||||
</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[];
|
||||
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,
|
||||
UpdateConsumerInput,
|
||||
CircuitTreeResponseDto,
|
||||
CreateCircuitInputDto,
|
||||
UpdateCircuitInputDto,
|
||||
CreateCircuitDeviceRowInputDto,
|
||||
UpdateCircuitDeviceRowInputDto,
|
||||
} from "../types";
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
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) {
|
||||
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user