Skip to content

Commit 54bf6b6

Browse files
committed
feat(frontend): hierarchical equipment picker wired to ISA-95 tree
Replace the flat hardcoded equipment select with a SCADA-style command-palette that reflects the real DB model (Enterprise → Site → Area → Line → Cell) via `GET /api/v1/hierarchy/tree`. - `EquipmentPicker`: trigger button with bracketed breadcrumb (`[EQ] cellName · SITE · AREA · LINE`) + `⌘⇧E` shortcut. Popover with search (token-split, case-insensitive), tree grouped by line, keyboard navigation (↑↓ Home End wrap, Enter commit, Esc restore), StatusRail per row, disabled cells greyed. Full ARIA (dialog+modal, listbox/option, activedescendant, expanded/haspopup/controls). - `lib/hierarchy`: ISA-95 DTO types, `useHierarchyTree` TanStack query (staleTime 60s), `flattenTree/searchCells/groupByLine`, `findCell` helpers. `EquipmentSelection` now carries `{lineId, lineName, cellId, cellName, siteName, areaName}` for live MetaStrip binding. - `TopBar`: MetaStrip binds to the live selection (`SITE / LINE / REV` instead of hardcoded `UNIT / CELL / REV`). - `AppShell`: `aria.selectedEquipment` shape migrated; old string payload ("P-01") is dropped via a new `validator` option on `useLocalStorage`, which also filters out stale shapes arriving on cross-tab `storage` events. Auto-seeds the first enabled cell when no selection exists. Zero new deps. All tokens-only (no hex literals). Icons via DS wrapper. Motion via `Motion.fadeInUp` + `--ds-motion-*` tokens. `npm run typecheck` / `npm run build` / `npm run check` all green. Bundle 453.33 kB / 143.36 kB gzipped (+3 kB vs baseline).
1 parent 2045892 commit 54bf6b6

5 files changed

Lines changed: 908 additions & 46 deletions

File tree

frontend/src/app/AppShell.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { useCallback, useEffect, useId } from "react";
22
import { Outlet } from "react-router-dom";
3+
import type { EquipmentSelection } from "../lib/hierarchy";
34
import { useLocalStorage } from "../lib/useLocalStorage";
45
import { DRAWER_DEFAULT_WIDTH, DRAWER_MAX_WIDTH, DRAWER_MIN_WIDTH, Drawer } from "./Drawer";
5-
import { EQUIPMENT_OPTIONS, TopBar } from "./TopBar";
6+
import { TopBar } from "./TopBar";
67

78
interface ChatDrawerState {
89
open: boolean;
@@ -27,6 +28,15 @@ function sanitizeDrawer(state: ChatDrawerState): ChatDrawerState {
2728
};
2829
}
2930

31+
function validateEquipmentSelection(raw: unknown): EquipmentSelection | null {
32+
if (!raw || typeof raw !== "object") return null;
33+
const r = raw as Record<string, unknown>;
34+
if (typeof r.cellId !== "number" || typeof r.lineId !== "number") return null;
35+
if (typeof r.cellName !== "string" || typeof r.lineName !== "string") return null;
36+
if (typeof r.areaName !== "string" || typeof r.siteName !== "string") return null;
37+
return raw as EquipmentSelection;
38+
}
39+
3040
function isTypingTarget(target: EventTarget | null) {
3141
if (!(target instanceof HTMLElement)) return false;
3242
const tag = target.tagName;
@@ -40,9 +50,10 @@ export function AppShell() {
4050
CHAT_DRAWER_KEY,
4151
DEFAULT_DRAWER_STATE,
4252
);
43-
const [equipmentId, setEquipmentId] = useLocalStorage<string>(
53+
const [selection, setSelection] = useLocalStorage<EquipmentSelection | null>(
4454
EQUIPMENT_KEY,
45-
EQUIPMENT_OPTIONS[0].id,
55+
null,
56+
{ validator: validateEquipmentSelection },
4657
);
4758

4859
const safeDrawer = sanitizeDrawer(drawer);
@@ -77,8 +88,8 @@ export function AppShell() {
7788
return (
7889
<div className="flex h-screen w-screen flex-col overflow-hidden bg-[var(--ds-bg-base)] text-[var(--ds-fg-primary)]">
7990
<TopBar
80-
equipmentId={equipmentId}
81-
onEquipmentChange={setEquipmentId}
91+
selection={selection}
92+
onSelectionChange={setSelection}
8293
drawerOpen={safeDrawer.open}
8394
drawerControlsId={drawerId}
8495
onDrawerToggle={toggleDrawer}

0 commit comments

Comments
 (0)