Project devices sidebar working

This commit is contained in:
2026-05-04 19:00:21 +02:00
parent 48b4dd0fc0
commit efbb81c13d
2 changed files with 311 additions and 10 deletions
+81
View File
@@ -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;
}
}
+221 -1
View File
@@ -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,6 +1043,72 @@ 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-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}
@@ -1035,6 +1254,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</tbody>
</table>
</div>
</div>
<p className="todo-hint">TODO Phase: Ctrl+Plus currently adds circuit at end of active section.</p>
</div>
);