Project devices sidebar working
This commit is contained in:
@@ -52,6 +52,81 @@ body {
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-editor-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-sidebar {
|
||||||
|
border: 1px solid #d9dee8;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-sidebar h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-sidebar input,
|
||||||
|
.project-device-sidebar select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #cfd7e5;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-item {
|
||||||
|
border: 1px solid #d5ddec;
|
||||||
|
background: #f8faff;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-item.selected {
|
||||||
|
border-color: #4c7dd9;
|
||||||
|
background: #edf3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions button {
|
||||||
|
border: 1px solid #c4cddc;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid {
|
.tree-grid {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
@@ -157,3 +232,9 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.tree-editor-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import {
|
|||||||
deleteCircuitDeviceRowById,
|
deleteCircuitDeviceRowById,
|
||||||
getCircuitTree,
|
getCircuitTree,
|
||||||
getNextCircuitIdentifier,
|
getNextCircuitIdentifier,
|
||||||
|
listProjectDevices,
|
||||||
renumberCircuitSection,
|
renumberCircuitSection,
|
||||||
updateCircuitById,
|
updateCircuitById,
|
||||||
updateCircuitDeviceRowById,
|
updateCircuitDeviceRowById,
|
||||||
} from "../utils/api";
|
} from "../utils/api";
|
||||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto, CircuitTreeResponseDto } from "../types";
|
import type {
|
||||||
|
CircuitTreeCircuitDto,
|
||||||
|
CircuitTreeDeviceRowDto,
|
||||||
|
CircuitTreeResponseDto,
|
||||||
|
ProjectDeviceDto,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
type CellKey =
|
type CellKey =
|
||||||
| "equipmentIdentifier"
|
| "equipmentIdentifier"
|
||||||
@@ -250,6 +256,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
|
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
||||||
|
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
||||||
|
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
||||||
|
const [targetCircuitId, setTargetCircuitId] = useState<string | 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);
|
||||||
@@ -278,6 +289,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
void loadTree({ showLoading: true });
|
void loadTree({ showLoading: true });
|
||||||
}, [projectId, circuitListId]);
|
}, [projectId, circuitListId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadProjectDeviceList() {
|
||||||
|
try {
|
||||||
|
const list = await listProjectDevices(projectId);
|
||||||
|
setProjectDevices(list);
|
||||||
|
} catch (err) {
|
||||||
|
setError(normalizeUiError(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadProjectDeviceList();
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
const visibleRows = useMemo(() => {
|
const visibleRows = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return [] as VisibleGridRow[];
|
return [] as VisibleGridRow[];
|
||||||
@@ -309,6 +332,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return rows;
|
return rows;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const searchableProjectDevices = useMemo(() => {
|
||||||
|
const term = projectDeviceSearch.trim().toLowerCase();
|
||||||
|
if (!term) {
|
||||||
|
return projectDevices;
|
||||||
|
}
|
||||||
|
return projectDevices.filter((device) => {
|
||||||
|
const haystack = `${device.name} ${device.displayName}`.toLowerCase();
|
||||||
|
return haystack.includes(term);
|
||||||
|
});
|
||||||
|
}, [projectDeviceSearch, projectDevices]);
|
||||||
|
|
||||||
|
const circuitOptions = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return [] as Array<{ id: string; label: string; sectionId: string }>;
|
||||||
|
}
|
||||||
|
return data.sections.flatMap((section) =>
|
||||||
|
section.circuits.map((circuit) => ({
|
||||||
|
id: circuit.id,
|
||||||
|
sectionId: section.id,
|
||||||
|
label: `${circuit.equipmentIdentifier} - ${circuit.displayName || "Circuit"}`,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
function makeRow(
|
function makeRow(
|
||||||
rowType: RowType,
|
rowType: RowType,
|
||||||
sectionId: string,
|
sectionId: string,
|
||||||
@@ -754,6 +801,112 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePhaseType(device: ProjectDeviceDto): string {
|
||||||
|
if (device.phaseCount === 3) {
|
||||||
|
return "three_phase";
|
||||||
|
}
|
||||||
|
return "single_phase";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSelectedProjectDevice() {
|
||||||
|
if (!selectedProjectDeviceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projectDevices.find((device) => device.id === selectedProjectDeviceId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddProjectDeviceAsNewCircuit() {
|
||||||
|
const device = resolveSelectedProjectDevice();
|
||||||
|
if (!device) {
|
||||||
|
setError("Please select a project device.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!targetSectionId) {
|
||||||
|
setError("Please select a target section.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
const section = data?.sections.find((entry) => entry.id === targetSectionId);
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Invalid target section.");
|
||||||
|
}
|
||||||
|
const next = await getNextCircuitIdentifier(targetSectionId);
|
||||||
|
const sortOrder =
|
||||||
|
section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||||
|
const createdCircuit = (await createCircuit(projectId, circuitListId, {
|
||||||
|
sectionId: targetSectionId,
|
||||||
|
equipmentIdentifier: next.nextIdentifier,
|
||||||
|
displayName: device.displayName || device.name,
|
||||||
|
sortOrder,
|
||||||
|
isReserve: false,
|
||||||
|
})) as CircuitTreeCircuitDto;
|
||||||
|
|
||||||
|
const createdRow = (await createCircuitDeviceRow(createdCircuit.id, {
|
||||||
|
linkedProjectDeviceId: device.id,
|
||||||
|
name: device.name,
|
||||||
|
displayName: device.displayName,
|
||||||
|
phaseType: resolvePhaseType(device),
|
||||||
|
quantity: device.quantity,
|
||||||
|
powerPerUnit: device.installedPowerPerUnitKw,
|
||||||
|
simultaneityFactor: device.demandFactor,
|
||||||
|
cosPhi: device.powerFactor ?? undefined,
|
||||||
|
category: device.category ?? undefined,
|
||||||
|
})) as { id: string };
|
||||||
|
|
||||||
|
await loadTree({ showLoading: false });
|
||||||
|
setActiveSectionId(targetSectionId);
|
||||||
|
setTargetCircuitId(createdCircuit.id);
|
||||||
|
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
|
||||||
|
} catch (err) {
|
||||||
|
setError(normalizeUiError(err));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddProjectDeviceToCircuit() {
|
||||||
|
const device = resolveSelectedProjectDevice();
|
||||||
|
if (!device) {
|
||||||
|
setError("Please select a project device.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let circuitId = targetCircuitId;
|
||||||
|
if (!circuitId && selectedCell) {
|
||||||
|
const row = findRow(selectedCell.rowKey);
|
||||||
|
if (row?.circuit?.id) {
|
||||||
|
circuitId = row.circuit.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!circuitId) {
|
||||||
|
setError("Please select a target circuit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
const createdRow = (await createCircuitDeviceRow(circuitId, {
|
||||||
|
linkedProjectDeviceId: device.id,
|
||||||
|
name: device.name,
|
||||||
|
displayName: device.displayName,
|
||||||
|
phaseType: resolvePhaseType(device),
|
||||||
|
quantity: device.quantity,
|
||||||
|
powerPerUnit: device.installedPowerPerUnitKw,
|
||||||
|
simultaneityFactor: device.demandFactor,
|
||||||
|
cosPhi: device.powerFactor ?? undefined,
|
||||||
|
category: device.category ?? undefined,
|
||||||
|
})) as { id: string };
|
||||||
|
await loadTree({ showLoading: false });
|
||||||
|
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
|
||||||
|
} catch (err) {
|
||||||
|
setError(normalizeUiError(err));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteDevice(rowId: string) {
|
async function handleDeleteDevice(rowId: string) {
|
||||||
if (!confirm("Delete this device row?")) {
|
if (!confirm("Delete this device row?")) {
|
||||||
return;
|
return;
|
||||||
@@ -890,14 +1043,80 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return (
|
return (
|
||||||
<div className="tree-editor-shell">
|
<div className="tree-editor-shell">
|
||||||
{isSaving ? <div className="notice info">Saving...</div> : null}
|
{isSaving ? <div className="notice info">Saving...</div> : null}
|
||||||
<div
|
<div className="tree-editor-layout">
|
||||||
className="tree-grid-wrap"
|
<aside className="project-device-sidebar">
|
||||||
ref={containerRef}
|
<h3>Project devices</h3>
|
||||||
tabIndex={0}
|
<input
|
||||||
onFocus={handleContainerFocus}
|
type="text"
|
||||||
onKeyDown={handleContainerKeyDown}
|
placeholder="Search name/displayName..."
|
||||||
>
|
value={projectDeviceSearch}
|
||||||
<table className="tree-grid">
|
onChange={(event) => setProjectDeviceSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="project-device-list">
|
||||||
|
{searchableProjectDevices.map((device) => (
|
||||||
|
<button
|
||||||
|
key={device.id}
|
||||||
|
type="button"
|
||||||
|
className={`project-device-item ${selectedProjectDeviceId === device.id ? "selected" : ""}`}
|
||||||
|
onClick={() => setSelectedProjectDeviceId(device.id)}
|
||||||
|
>
|
||||||
|
<strong>{device.displayName || device.name}</strong>
|
||||||
|
<span>Name: {device.name}</span>
|
||||||
|
<span>Phase: {device.phaseCount === 3 ? "three_phase" : "single_phase"}</span>
|
||||||
|
<span>Qty: {device.quantity}</span>
|
||||||
|
<span>P/unit: {device.installedPowerPerUnitKw}</span>
|
||||||
|
<span>g: {device.demandFactor}</span>
|
||||||
|
<span>Cost group: -</span>
|
||||||
|
<span>Category: {device.category || "-"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{searchableProjectDevices.length === 0 ? <p className="notice muted">No matching project devices.</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-actions">
|
||||||
|
<label>
|
||||||
|
Target section
|
||||||
|
<select
|
||||||
|
value={targetSectionId ?? ""}
|
||||||
|
onChange={(event) => setTargetSectionId(event.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">Select section...</option>
|
||||||
|
{data.sections.map((section) => (
|
||||||
|
<option key={section.id} value={section.id}>
|
||||||
|
{section.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={() => void handleAddProjectDeviceAsNewCircuit()}>
|
||||||
|
Add as new circuit
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
Target circuit
|
||||||
|
<select
|
||||||
|
value={targetCircuitId ?? ""}
|
||||||
|
onChange={(event) => setTargetCircuitId(event.target.value || null)}
|
||||||
|
>
|
||||||
|
<option value="">Use selected row circuit...</option>
|
||||||
|
{circuitOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" onClick={() => void handleAddProjectDeviceToCircuit()}>
|
||||||
|
Add to selected circuit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
className="tree-grid-wrap"
|
||||||
|
ref={containerRef}
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={handleContainerFocus}
|
||||||
|
onKeyDown={handleContainerKeyDown}
|
||||||
|
>
|
||||||
|
<table className="tree-grid">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
@@ -1033,7 +1252,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds circuit at end of active section.</p>
|
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds circuit at end of active section.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user