Added column sorting
This commit is contained in:
@@ -49,6 +49,7 @@ body {
|
|||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar button {
|
.editor-toolbar button {
|
||||||
@@ -63,6 +64,75 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-settings-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9;
|
||||||
|
margin-top: 2rem;
|
||||||
|
border: 1px solid #cfd7e5;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 0.45rem;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 340px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item.drop-target {
|
||||||
|
border-color: #2b6cb0;
|
||||||
|
background: #ebf4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item.locked {
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-item label {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-order {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-settings-order button {
|
||||||
|
border: 1px solid #c4cddc;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.08rem 0.28rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid-wrap {
|
.tree-grid-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid #d9dee8;
|
border: 1px solid #d9dee8;
|
||||||
|
|||||||
@@ -34,7 +34,26 @@ type CellKey =
|
|||||||
| "protectionSummary"
|
| "protectionSummary"
|
||||||
| "cableSummary"
|
| "cableSummary"
|
||||||
| "roomSummary"
|
| "roomSummary"
|
||||||
| "remark";
|
| "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";
|
||||||
@@ -118,37 +137,117 @@ type CircuitReorderDropIntent =
|
|||||||
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
||||||
| { kind: "section-end"; sectionId: string; valid: boolean };
|
| { kind: "section-end"; sectionId: string; valid: boolean };
|
||||||
|
|
||||||
const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
|
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
|
||||||
{ key: "equipmentIdentifier", label: "Equipment identifier" },
|
|
||||||
{ key: "displayName", label: "Display name" },
|
interface ColumnDef {
|
||||||
{ key: "quantity", label: "Quantity", numeric: true },
|
key: CellKey;
|
||||||
{ key: "powerPerUnit", label: "Power / unit", numeric: true },
|
label: string;
|
||||||
{ key: "simultaneityFactor", label: "Simultaneity", numeric: true },
|
numeric?: boolean;
|
||||||
{ key: "rowTotalPower", label: "Row total", numeric: true },
|
defaultVisible?: boolean;
|
||||||
{ key: "circuitTotalPower", label: "Circuit total", numeric: true },
|
locked?: boolean;
|
||||||
{ key: "protectionSummary", label: "Protection summary" },
|
}
|
||||||
{ key: "cableSummary", label: "Cable summary" },
|
|
||||||
{ key: "roomSummary", label: "Room" },
|
const allColumns: ColumnDef[] = [
|
||||||
{ key: "remark", label: "Remark" },
|
{ 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 deviceOnlyColumns = new Set<CellKey>(["quantity", "powerPerUnit", "simultaneityFactor", "rowTotalPower", "roomSummary"]);
|
const defaultVisibleColumnKeys = allColumns.filter((column) => column.defaultVisible).map((column) => column.key);
|
||||||
const circuitOnlyColumns = new Set<CellKey>(["equipmentIdentifier", "protectionSummary", "cableSummary", "circuitTotalPower"]);
|
|
||||||
|
|
||||||
const deviceFieldKeys = new Set<CellKey>([
|
const deviceOnlyColumns = new Set<CellKey>([
|
||||||
"displayName",
|
|
||||||
"roomSummary",
|
|
||||||
"quantity",
|
"quantity",
|
||||||
"powerPerUnit",
|
"powerPerUnit",
|
||||||
"simultaneityFactor",
|
"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",
|
"remark",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const circuitFieldKeys = new Set<CellKey>([
|
const circuitFieldKeys = new Set<CellKey>([
|
||||||
"equipmentIdentifier",
|
"equipmentIdentifier",
|
||||||
"displayName",
|
"displayName",
|
||||||
|
"protectionType",
|
||||||
|
"protectionRatedCurrent",
|
||||||
|
"protectionCharacteristic",
|
||||||
"protectionSummary",
|
"protectionSummary",
|
||||||
|
"cableType",
|
||||||
|
"cableCrossSection",
|
||||||
|
"cableLength",
|
||||||
"cableSummary",
|
"cableSummary",
|
||||||
|
"rcdAssignment",
|
||||||
|
"terminalDesignation",
|
||||||
|
"status",
|
||||||
|
"isReserve",
|
||||||
"remark",
|
"remark",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -198,16 +297,34 @@ function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
|||||||
|
|
||||||
function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): string | number | boolean | undefined {
|
function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): string | number | boolean | undefined {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
|
case "technicalName":
|
||||||
|
return device.name;
|
||||||
case "displayName":
|
case "displayName":
|
||||||
return device.displayName || device.name;
|
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":
|
case "roomSummary":
|
||||||
return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
||||||
|
case "roomNumberSnapshot":
|
||||||
|
return device.roomNumberSnapshot;
|
||||||
|
case "roomNameSnapshot":
|
||||||
|
return device.roomNameSnapshot;
|
||||||
case "quantity":
|
case "quantity":
|
||||||
return device.quantity;
|
return device.quantity;
|
||||||
case "powerPerUnit":
|
case "powerPerUnit":
|
||||||
return device.powerPerUnit;
|
return device.powerPerUnit;
|
||||||
case "simultaneityFactor":
|
case "simultaneityFactor":
|
||||||
return device.simultaneityFactor;
|
return device.simultaneityFactor;
|
||||||
|
case "cosPhi":
|
||||||
|
return device.cosPhi;
|
||||||
case "rowTotalPower":
|
case "rowTotalPower":
|
||||||
return device.rowTotalPower;
|
return device.rowTotalPower;
|
||||||
case "remark":
|
case "remark":
|
||||||
@@ -225,6 +342,12 @@ function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string |
|
|||||||
return circuit.displayName;
|
return circuit.displayName;
|
||||||
case "circuitTotalPower":
|
case "circuitTotalPower":
|
||||||
return circuit.circuitTotalPower;
|
return circuit.circuitTotalPower;
|
||||||
|
case "protectionType":
|
||||||
|
return circuit.protectionType;
|
||||||
|
case "protectionRatedCurrent":
|
||||||
|
return circuit.protectionRatedCurrent;
|
||||||
|
case "protectionCharacteristic":
|
||||||
|
return circuit.protectionCharacteristic;
|
||||||
case "protectionSummary": {
|
case "protectionSummary": {
|
||||||
const current =
|
const current =
|
||||||
circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
||||||
@@ -240,6 +363,20 @@ function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): string |
|
|||||||
circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
||||||
return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined;
|
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":
|
case "remark":
|
||||||
return circuit.remark;
|
return circuit.remark;
|
||||||
default:
|
default:
|
||||||
@@ -350,12 +487,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
|
const [sortState, setSortState] = useState<{ key: CellKey; direction: SortDirection } | null>(null);
|
||||||
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
|
const [columnFilters, setColumnFilters] = useState<Partial<Record<CellKey, string[]>>>({});
|
||||||
const [openFilterColumn, setOpenFilterColumn] = useState<CellKey | null>(null);
|
const [openFilterColumn, setOpenFilterColumn] = useState<CellKey | null>(null);
|
||||||
|
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
|
||||||
|
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
|
||||||
|
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
||||||
|
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
|
||||||
|
const [columnDropTargetKey, setColumnDropTargetKey] = useState<CellKey | null>(null);
|
||||||
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
||||||
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const focusTokenRef = useRef(1);
|
const focusTokenRef = useRef(1);
|
||||||
|
|
||||||
|
const orderedColumns = useMemo(
|
||||||
|
() => columnOrder.map((key) => allColumns.find((column) => column.key === key)).filter(Boolean) as ColumnDef[],
|
||||||
|
[columnOrder]
|
||||||
|
);
|
||||||
|
const visibleColumns = useMemo(
|
||||||
|
() => orderedColumns.filter((column) => visibleColumnKeys.includes(column.key)),
|
||||||
|
[orderedColumns, visibleColumnKeys]
|
||||||
|
);
|
||||||
|
|
||||||
async function loadTree(options?: { showLoading?: boolean }) {
|
async function loadTree(options?: { showLoading?: boolean }) {
|
||||||
const showLoading = options?.showLoading ?? false;
|
const showLoading = options?.showLoading ?? false;
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
@@ -374,6 +525,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeColumnOrder(keys: CellKey[]) {
|
||||||
|
const unique = [...new Set(keys)];
|
||||||
|
const allKeys = allColumns.map((column) => column.key);
|
||||||
|
const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))];
|
||||||
|
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTree({ showLoading: true });
|
void loadTree({ showLoading: true });
|
||||||
}, [projectId, circuitListId]);
|
}, [projectId, circuitListId]);
|
||||||
@@ -390,6 +548,54 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
void loadProjectDeviceList();
|
void loadProjectDeviceList();
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] };
|
||||||
|
const validKeys = new Set(allColumns.map((column) => column.key));
|
||||||
|
const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
||||||
|
const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
||||||
|
if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setColumnOrder(normalizeColumnOrder(parsedOrder));
|
||||||
|
setVisibleColumnKeys(parsedVisible);
|
||||||
|
} catch {
|
||||||
|
// ignore invalid local storage and use defaults
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const payload = {
|
||||||
|
order: columnOrder,
|
||||||
|
visible: visibleColumnKeys,
|
||||||
|
};
|
||||||
|
localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
}, [columnOrder, visibleColumnKeys]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const visible = new Set(visibleColumnKeys);
|
||||||
|
setSortState((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return visible.has(current.key) ? current : null;
|
||||||
|
});
|
||||||
|
setColumnFilters((current) => {
|
||||||
|
const next: Partial<Record<CellKey, string[]>> = {};
|
||||||
|
for (const [key, values] of Object.entries(current)) {
|
||||||
|
if (visible.has(key as CellKey) && values && values.length > 0) {
|
||||||
|
next[key as CellKey] = values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setOpenFilterColumn((current) => (current && visible.has(current) ? current : null));
|
||||||
|
}, [visibleColumnKeys]);
|
||||||
|
|
||||||
const hasActiveFilters = useMemo(
|
const hasActiveFilters = useMemo(
|
||||||
() => Object.values(columnFilters).some((values) => (values?.length ?? 0) > 0),
|
() => Object.values(columnFilters).some((values) => (values?.length ?? 0) > 0),
|
||||||
[columnFilters]
|
[columnFilters]
|
||||||
@@ -397,7 +603,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
|
|
||||||
const distinctValuesByColumn = useMemo(() => {
|
const distinctValuesByColumn = useMemo(() => {
|
||||||
const result = {} as Record<CellKey, string[]>;
|
const result = {} as Record<CellKey, string[]>;
|
||||||
for (const column of columns) {
|
for (const column of visibleColumns) {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const section of data?.sections ?? []) {
|
for (const section of data?.sections ?? []) {
|
||||||
for (const circuit of section.circuits) {
|
for (const circuit of section.circuits) {
|
||||||
@@ -410,7 +616,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
result[column.key] = [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [data]);
|
}, [data, visibleColumns]);
|
||||||
|
|
||||||
const filteredSortedSections = useMemo(() => {
|
const filteredSortedSections = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -492,7 +698,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
rowKey: `section:${section.id}`,
|
rowKey: `section:${section.id}`,
|
||||||
rowType: "section",
|
rowType: "section",
|
||||||
sectionId: section.id,
|
sectionId: section.id,
|
||||||
cells: columns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })),
|
cells: allColumns.map((col) => ({ cellKey: col.key, editable: false, kind: "readonly", value: undefined })),
|
||||||
});
|
});
|
||||||
for (const circuit of section.circuits) {
|
for (const circuit of section.circuits) {
|
||||||
if (circuit.deviceRows.length === 0) {
|
if (circuit.deviceRows.length === 0) {
|
||||||
@@ -549,7 +755,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
: rowType === "deviceRow" && device
|
: rowType === "deviceRow" && device
|
||||||
? `device:${device.id}`
|
? `device:${device.id}`
|
||||||
: `${rowType}:${circuit?.id ?? sectionId}`;
|
: `${rowType}:${circuit?.id ?? sectionId}`;
|
||||||
const cells = columns.map((col) => {
|
const cells = allColumns.map((col) => {
|
||||||
const kind = getCellKind(rowType, col.key);
|
const kind = getCellKind(rowType, col.key);
|
||||||
const editable = kind === "circuitField" || kind === "deviceField";
|
const editable = kind === "circuitField" || kind === "deviceField";
|
||||||
let value: string | number | boolean | undefined;
|
let value: string | number | boolean | undefined;
|
||||||
@@ -580,15 +786,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
);
|
);
|
||||||
|
|
||||||
const rowCellMap = useMemo(() => {
|
const rowCellMap = useMemo(() => {
|
||||||
|
const visibleKeySet = new Set(visibleColumnKeys);
|
||||||
const map = new Map<string, CellKey[]>();
|
const map = new Map<string, CellKey[]>();
|
||||||
for (const row of visibleRows) {
|
for (const row of visibleRows) {
|
||||||
const keys = row.cells.filter((cell) => cell.editable).map((cell) => cell.cellKey as CellKey);
|
const keys = row.cells
|
||||||
|
.filter((cell) => cell.editable && visibleKeySet.has(cell.cellKey))
|
||||||
|
.map((cell) => cell.cellKey as CellKey);
|
||||||
if (keys.length) {
|
if (keys.length) {
|
||||||
map.set(row.rowKey, keys);
|
map.set(row.rowKey, keys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [visibleRows]);
|
}, [visibleRows, visibleColumnKeys]);
|
||||||
|
|
||||||
const editableRowOrder = useMemo(
|
const editableRowOrder = useMemo(
|
||||||
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
|
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
|
||||||
@@ -783,6 +992,103 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleColumnVisibility(key: CellKey) {
|
||||||
|
const column = allColumns.find((entry) => entry.key === key);
|
||||||
|
if (!column || column.locked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVisibleColumnKeys((current) => {
|
||||||
|
if (current.includes(key)) {
|
||||||
|
return current.filter((entry) => entry !== key);
|
||||||
|
}
|
||||||
|
return [...current, key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveColumn(key: CellKey, direction: -1 | 1) {
|
||||||
|
if (key === "equipmentIdentifier") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setColumnOrder((current) => {
|
||||||
|
const index = current.indexOf(key);
|
||||||
|
if (index < 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const nextIndex = index + direction;
|
||||||
|
if (nextIndex <= 0 || nextIndex >= current.length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const clone = [...current];
|
||||||
|
const [item] = clone.splice(index, 1);
|
||||||
|
clone.splice(nextIndex, 0, item);
|
||||||
|
return normalizeColumnOrder(clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetColumns() {
|
||||||
|
setVisibleColumnKeys(defaultVisibleColumnKeys);
|
||||||
|
setColumnOrder(normalizeColumnOrder(allColumns.map((column) => column.key)));
|
||||||
|
setOpenFilterColumn(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveColumnByDrag(dragKey: CellKey, targetKey: CellKey) {
|
||||||
|
if (dragKey === "equipmentIdentifier" || targetKey === "equipmentIdentifier") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setColumnOrder((current) => {
|
||||||
|
const from = current.indexOf(dragKey);
|
||||||
|
const to = current.indexOf(targetKey);
|
||||||
|
if (from < 1 || to < 1 || from === to) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const clone = [...current];
|
||||||
|
const [item] = clone.splice(from, 1);
|
||||||
|
const targetIndex = clone.indexOf(targetKey);
|
||||||
|
if (targetIndex < 1) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
clone.splice(targetIndex, 0, item);
|
||||||
|
return normalizeColumnOrder(clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnDragStart(event: DragEvent<HTMLDivElement>, key: CellKey) {
|
||||||
|
if (key === "equipmentIdentifier") {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraggingColumnKey(key);
|
||||||
|
setColumnDropTargetKey(null);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("application/x-column-key", key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnDragOver(event: DragEvent<HTMLDivElement>, targetKey: CellKey) {
|
||||||
|
if (!draggingColumnKey || targetKey === "equipmentIdentifier" || draggingColumnKey === targetKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
setColumnDropTargetKey(targetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnDrop(event: DragEvent<HTMLDivElement>, targetKey: CellKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
const dragKey = draggingColumnKey;
|
||||||
|
if (!dragKey || dragKey === targetKey) {
|
||||||
|
setColumnDropTargetKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moveColumnByDrag(dragKey, targetKey);
|
||||||
|
setDraggingColumnKey(null);
|
||||||
|
setColumnDropTargetKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColumnDragEnd() {
|
||||||
|
setDraggingColumnKey(null);
|
||||||
|
setColumnDropTargetKey(null);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intent = pendingSelectionAfterReload.current;
|
const intent = pendingSelectionAfterReload.current;
|
||||||
if (!intent || !data) {
|
if (!intent || !data) {
|
||||||
@@ -805,6 +1111,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setPendingFocus(null);
|
setPendingFocus(null);
|
||||||
}, [pendingFocus]);
|
}, [pendingFocus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isColumnMenuOpen) {
|
||||||
|
setDraggingColumnKey(null);
|
||||||
|
setColumnDropTargetKey(null);
|
||||||
|
}
|
||||||
|
}, [isColumnMenuOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editingCell) {
|
if (!editingCell) {
|
||||||
return;
|
return;
|
||||||
@@ -877,11 +1190,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
if (!targetCells.length) {
|
if (!targetCells.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preferred = columns.findIndex((col) => col.key === selectedCell.cellKey);
|
const preferred = visibleColumns.findIndex((col) => col.key === selectedCell.cellKey);
|
||||||
let best = targetCells[0];
|
let best = targetCells[0];
|
||||||
let bestDistance = Number.POSITIVE_INFINITY;
|
let bestDistance = Number.POSITIVE_INFINITY;
|
||||||
for (const key of targetCells) {
|
for (const key of targetCells) {
|
||||||
const idx = columns.findIndex((col) => col.key === key);
|
const idx = visibleColumns.findIndex((col) => col.key === key);
|
||||||
const dist = Math.abs(idx - preferred);
|
const dist = Math.abs(idx - preferred);
|
||||||
if (dist < bestDistance) {
|
if (dist < bestDistance) {
|
||||||
bestDistance = dist;
|
bestDistance = dist;
|
||||||
@@ -895,6 +1208,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (key === "protectionSummary" || key === "cableSummary") {
|
if (key === "protectionSummary" || key === "cableSummary") {
|
||||||
payload.remark = draft.trim() === "" ? undefined : draft;
|
payload.remark = draft.trim() === "" ? undefined : draft;
|
||||||
|
} else if (["protectionRatedCurrent", "cableLength"].includes(key)) {
|
||||||
|
payload[key] = parseNumeric(key, draft);
|
||||||
|
} else if (key === "isReserve") {
|
||||||
|
const normalized = draft.trim().toLowerCase();
|
||||||
|
payload.isReserve = ["1", "true", "yes", "ja"].includes(normalized);
|
||||||
} else {
|
} else {
|
||||||
payload[key] = draft.trim() === "" ? undefined : draft;
|
payload[key] = draft.trim() === "" ? undefined : draft;
|
||||||
}
|
}
|
||||||
@@ -903,7 +1221,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
|
|
||||||
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
async function patchDeviceRow(rowId: string, key: CellKey, draft: string) {
|
||||||
const payload: Record<string, unknown> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
if (["quantity", "powerPerUnit", "simultaneityFactor"].includes(key)) {
|
if (["quantity", "powerPerUnit", "simultaneityFactor", "cosPhi"].includes(key)) {
|
||||||
payload[key] = parseNumeric(key, draft);
|
payload[key] = parseNumeric(key, draft);
|
||||||
} else if (key === "roomSummary") {
|
} else if (key === "roomSummary") {
|
||||||
const trimmed = draft.trim();
|
const trimmed = draft.trim();
|
||||||
@@ -1846,18 +2164,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
|
<button type="button" onClick={() => void handleRedo()} disabled={redoStack.length === 0 || historyBusy || isSaving}>
|
||||||
Redo
|
Redo
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSortState(null);
|
setSortState(null);
|
||||||
setColumnFilters({});
|
setColumnFilters({});
|
||||||
setOpenFilterColumn(null);
|
setOpenFilterColumn(null);
|
||||||
}}
|
}}
|
||||||
disabled={!Boolean(sortState) && !hasActiveFilters}
|
disabled={!Boolean(sortState) && !hasActiveFilters}
|
||||||
>
|
>
|
||||||
Clear sort/filter
|
Clear sort/filter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button type="button" onClick={() => setIsColumnMenuOpen((current) => !current)}>
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
|
{isColumnMenuOpen ? (
|
||||||
|
<div className="column-settings-menu">
|
||||||
|
<div className="column-settings-actions">
|
||||||
|
<button type="button" onClick={() => resetColumns()}>
|
||||||
|
Reset columns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="column-settings-list">
|
||||||
|
{orderedColumns.map((column) => {
|
||||||
|
const visible = visibleColumnKeys.includes(column.key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`col-empty-${column.key}`}
|
||||||
|
className={`column-settings-item ${draggingColumnKey === column.key ? "dragging" : ""} ${columnDropTargetKey === column.key ? "drop-target" : ""} ${column.locked ? "locked" : ""}`}
|
||||||
|
draggable={!column.locked}
|
||||||
|
onDragStart={(event) => handleColumnDragStart(event, column.key)}
|
||||||
|
onDragOver={(event) => handleColumnDragOver(event, column.key)}
|
||||||
|
onDrop={(event) => handleColumnDrop(event, column.key)}
|
||||||
|
onDragEnd={() => handleColumnDragEnd()}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible}
|
||||||
|
disabled={column.locked}
|
||||||
|
onChange={() => toggleColumnVisibility(column.key)}
|
||||||
|
/>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</label>
|
||||||
|
<div className="column-settings-order">
|
||||||
|
<button type="button" onClick={() => moveColumn(column.key, -1)}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => moveColumn(column.key, 1)}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="notice muted">No matching circuits for active filters.</div>
|
<div className="notice muted">No matching circuits for active filters.</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1885,6 +2249,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
Apply sorted order
|
Apply sorted order
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button type="button" onClick={() => setIsColumnMenuOpen((current) => !current)}>
|
||||||
|
Columns
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1896,6 +2263,49 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
>
|
>
|
||||||
Clear sort/filter
|
Clear sort/filter
|
||||||
</button>
|
</button>
|
||||||
|
{isColumnMenuOpen ? (
|
||||||
|
<div className="column-settings-menu">
|
||||||
|
<div className="column-settings-actions">
|
||||||
|
<button type="button" onClick={() => resetColumns()}>
|
||||||
|
Reset columns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="column-settings-list">
|
||||||
|
{orderedColumns.map((column) => {
|
||||||
|
const visible = visibleColumnKeys.includes(column.key);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`col-${column.key}`}
|
||||||
|
className={`column-settings-item ${draggingColumnKey === column.key ? "dragging" : ""} ${columnDropTargetKey === column.key ? "drop-target" : ""} ${column.locked ? "locked" : ""}`}
|
||||||
|
draggable={!column.locked}
|
||||||
|
onDragStart={(event) => handleColumnDragStart(event, column.key)}
|
||||||
|
onDragOver={(event) => handleColumnDragOver(event, column.key)}
|
||||||
|
onDrop={(event) => handleColumnDrop(event, column.key)}
|
||||||
|
onDragEnd={() => handleColumnDragEnd()}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible}
|
||||||
|
disabled={column.locked}
|
||||||
|
onChange={() => toggleColumnVisibility(column.key)}
|
||||||
|
/>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
</label>
|
||||||
|
<div className="column-settings-order">
|
||||||
|
<button type="button" onClick={() => moveColumn(column.key, -1)}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => moveColumn(column.key, 1)}>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{hasActiveSortOrFilter ? (
|
{hasActiveSortOrFilter ? (
|
||||||
<div className="notice muted">Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.</div>
|
<div className="notice muted">Sorting/filtering is view-only. Renumber is disabled while sort/filter is active.</div>
|
||||||
@@ -1995,7 +2405,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<table className="tree-grid">
|
<table className="tree-grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((column) => (
|
{visibleColumns.map((column) => (
|
||||||
<th key={column.key} className={column.numeric ? "num" : ""}>
|
<th key={column.key} className={column.numeric ? "num" : ""}>
|
||||||
<div className="header-cell">
|
<div className="header-cell">
|
||||||
<button
|
<button
|
||||||
@@ -2119,7 +2529,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td colSpan={columns.length + 1} className="section-drop-cell">
|
<td colSpan={visibleColumns.length + 1} className="section-drop-cell">
|
||||||
<div className="section-content">
|
<div className="section-content">
|
||||||
<strong>{section.displayName}</strong>
|
<strong>{section.displayName}</strong>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
@@ -2364,7 +2774,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map((column) => {
|
{visibleColumns.map((column) => {
|
||||||
const cell = row.cells.find((entry) => entry.cellKey === column.key)!;
|
const cell = row.cells.find((entry) => entry.cellKey === column.key)!;
|
||||||
const isSelected =
|
const isSelected =
|
||||||
selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;
|
selectedCell?.rowKey === row.rowKey && selectedCell.cellKey === column.key;
|
||||||
|
|||||||
Reference in New Issue
Block a user