Project devices sidebar working
This commit is contained in:
@@ -52,6 +52,81 @@ body {
|
||||
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 {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
@@ -157,3 +232,9 @@ body {
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.tree-editor-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
deleteCircuitDeviceRowById,
|
||||
getCircuitTree,
|
||||
getNextCircuitIdentifier,
|
||||
listProjectDevices,
|
||||
renumberCircuitSection,
|
||||
updateCircuitById,
|
||||
updateCircuitDeviceRowById,
|
||||
} from "../utils/api";
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto, CircuitTreeResponseDto } from "../types";
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
CircuitTreeResponseDto,
|
||||
ProjectDeviceDto,
|
||||
} from "../types";
|
||||
|
||||
type CellKey =
|
||||
| "equipmentIdentifier"
|
||||
@@ -250,6 +256,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||
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 pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -278,6 +289,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
void loadTree({ showLoading: true });
|
||||
}, [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(() => {
|
||||
if (!data) {
|
||||
return [] as VisibleGridRow[];
|
||||
@@ -309,6 +332,30 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return rows;
|
||||
}, [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(
|
||||
rowType: RowType,
|
||||
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) {
|
||||
if (!confirm("Delete this device row?")) {
|
||||
return;
|
||||
@@ -890,14 +1043,80 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return (
|
||||
<div className="tree-editor-shell">
|
||||
{isSaving ? <div className="notice info">Saving...</div> : null}
|
||||
<div
|
||||
className="tree-grid-wrap"
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
onFocus={handleContainerFocus}
|
||||
onKeyDown={handleContainerKeyDown}
|
||||
>
|
||||
<table className="tree-grid">
|
||||
<div className="tree-editor-layout">
|
||||
<aside className="project-device-sidebar">
|
||||
<h3>Project devices</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name/displayName..."
|
||||
value={projectDeviceSearch}
|
||||
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>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
@@ -1033,7 +1252,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds circuit at end of active section.</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user