From a4f32b2b49aa0dd97dfc33667cfe42d2c16ee72e Mon Sep 17 00:00:00 2001 From: Forbes Date: Fri, 6 Feb 2026 17:21:18 -0600 Subject: [PATCH] feat(web): migrate Items page to React with UI improvements Phase 2 of frontend migration (epic #6, issue #8). Rebuild the Items page (4,243 lines of vanilla JS) as 16 React components with full feature parity plus UI improvements. UI improvements: - Footer stats bar (28px fixed bottom) replacing top stat cards - Compact row density (28-32px) with alternating background colors - Right-click column configuration via reusable ContextMenu component - Resizable horizontal/vertical split panel layout (persisted) - In-pane CRUD forms replacing modal dialogs (Infor-style) Components (web/src/components/items/): - ItemTable: sortable columns, alternating rows, column config - ItemsToolbar: search with scope (All/PN/Desc), filters, actions - SplitPanel: drag-resizable horizontal/vertical container - FooterStats: fixed bottom bar with reactive item counts - ItemDetail: 5-tab detail pane (Main, Properties, Revisions, BOM, Where Used) with header actions - MainTab: metadata, inline project tag editor, file download - PropertiesTab: form/JSON dual-mode editor, save as new revision - RevisionsTab: comparison diff, status management, rollback - BOMTab: inline CRUD, cost calculations, CSV export - WhereUsedTab: parent assemblies table - CreateItemPane: in-pane form with schema category properties - EditItemPane: in-pane edit form for basic fields - DeleteItemPane: in-pane confirmation with warning - ImportItemsPane: CSV upload with dry-run validation flow Shared components: - ContextMenu: positioned right-click menu with checkbox support Hooks: - useItems: items fetching with search, filters, pagination, debounce - useLocalStorage: typed localStorage state hook Extended api/types.ts with request/response types for search, BOM, revisions, CSV import, schema properties, and revision comparison. --- web/src/api/types.ts | 103 ++++++- web/src/components/ContextMenu.tsx | 105 +++++++ web/src/components/items/BOMTab.tsx | 238 ++++++++++++++ web/src/components/items/CreateItemPane.tsx | 215 +++++++++++++ web/src/components/items/DeleteItemPane.tsx | 84 +++++ web/src/components/items/EditItemPane.tsx | 150 +++++++++ web/src/components/items/FooterStats.tsx | 36 +++ web/src/components/items/ImportItemsPane.tsx | 179 +++++++++++ web/src/components/items/ItemDetail.tsx | 202 ++++++++++++ web/src/components/items/ItemTable.tsx | 262 ++++++++++++++++ web/src/components/items/ItemsToolbar.tsx | 152 +++++++++ web/src/components/items/MainTab.tsx | 169 ++++++++++ web/src/components/items/PropertiesTab.tsx | 209 +++++++++++++ web/src/components/items/RevisionsTab.tsx | 207 +++++++++++++ web/src/components/items/SplitPanel.tsx | 111 +++++++ web/src/components/items/WhereUsedTab.tsx | 60 ++++ web/src/hooks/useItems.ts | 89 ++++++ web/src/hooks/useLocalStorage.ts | 26 ++ web/src/pages/ItemsPage.tsx | 307 +++++++++++++++---- 19 files changed, 2846 insertions(+), 58 deletions(-) create mode 100644 web/src/components/ContextMenu.tsx create mode 100644 web/src/components/items/BOMTab.tsx create mode 100644 web/src/components/items/CreateItemPane.tsx create mode 100644 web/src/components/items/DeleteItemPane.tsx create mode 100644 web/src/components/items/EditItemPane.tsx create mode 100644 web/src/components/items/FooterStats.tsx create mode 100644 web/src/components/items/ImportItemsPane.tsx create mode 100644 web/src/components/items/ItemDetail.tsx create mode 100644 web/src/components/items/ItemTable.tsx create mode 100644 web/src/components/items/ItemsToolbar.tsx create mode 100644 web/src/components/items/MainTab.tsx create mode 100644 web/src/components/items/PropertiesTab.tsx create mode 100644 web/src/components/items/RevisionsTab.tsx create mode 100644 web/src/components/items/SplitPanel.tsx create mode 100644 web/src/components/items/WhereUsedTab.tsx create mode 100644 web/src/hooks/useItems.ts create mode 100644 web/src/hooks/useLocalStorage.ts diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9d04cc7..d6b66e0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -3,7 +3,7 @@ export interface User { username: string; display_name: string; email: string; - role: 'admin' | 'editor' | 'viewer'; + role: "admin" | "editor" | "viewer"; auth_source: string; } @@ -127,3 +127,104 @@ export interface ErrorResponse { error: string; message?: string; } + +// Search +export interface FuzzyResult extends Item { + score: number; +} + +// Where Used +export interface WhereUsedEntry { + id: string; + parent_part_number: string; + parent_description: string; + rel_type: string; + quantity: number | null; + unit?: string; + reference_designators?: string[]; +} + +// CSV Import +export interface CSVImportResult { + total_rows: number; + success_count: number; + error_count: number; + errors?: CSVImportError[]; + created_items?: string[]; +} + +export interface CSVImportError { + row: number; + field?: string; + message: string; +} + +// Request types +export interface CreateItemRequest { + schema?: string; + category: string; + description: string; + projects?: string[]; + properties?: Record; + sourcing_type?: string; + sourcing_link?: string; + long_description?: string; + standard_cost?: number; +} + +export interface UpdateItemRequest { + part_number?: string; + item_type?: string; + description?: string; + properties?: Record; + comment?: string; + sourcing_type?: string; + sourcing_link?: string; + long_description?: string; + standard_cost?: number; +} + +export interface CreateRevisionRequest { + properties: Record; + comment: string; +} + +export interface AddBOMEntryRequest { + child_part_number: string; + rel_type?: string; + quantity?: number; + unit?: string; + reference_designators?: string[]; + child_revision?: number; + metadata?: Record; +} + +export interface UpdateBOMEntryRequest { + rel_type?: string; + quantity?: number; + unit?: string; + reference_designators?: string[]; + child_revision?: number; + metadata?: Record; +} + +// Schema properties +export interface PropertyDef { + type: string; + required?: boolean; + description?: string; + default?: unknown; +} + +export type PropertySchema = Record; + +// Revision comparison +export interface RevisionComparison { + from: number; + to: number; + added: Record; + removed: Record; + changed: Record; + status_changed?: { from: string; to: string }; + file_changed?: boolean; +} diff --git a/web/src/components/ContextMenu.tsx b/web/src/components/ContextMenu.tsx new file mode 100644 index 0000000..13e4e6c --- /dev/null +++ b/web/src/components/ContextMenu.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef } from 'react'; + +export interface ContextMenuItem { + label: string; + checked?: boolean; + onToggle?: () => void; + onClick?: () => void; + divider?: boolean; + disabled?: boolean; +} + +interface ContextMenuProps { + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; +} + +export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const ref = useRef(null); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + const handleScroll = () => onClose(); + + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKey); + window.addEventListener('scroll', handleScroll, true); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKey); + window.removeEventListener('scroll', handleScroll, true); + }; + }, [onClose]); + + // Clamp position to viewport + const style: React.CSSProperties = { + position: 'fixed', + left: Math.min(x, window.innerWidth - 220), + top: Math.min(y, window.innerHeight - items.length * 32 - 16), + zIndex: 9999, + backgroundColor: 'var(--ctp-surface0)', + border: '1px solid var(--ctp-surface1)', + borderRadius: '0.5rem', + padding: '0.25rem 0', + minWidth: 200, + boxShadow: '0 4px 12px rgba(0,0,0,0.4)', + }; + + return ( +
+ {items.map((item, i) => + item.divider ? ( +
+ ) : ( + + ), + )} +
+ ); +} diff --git a/web/src/components/items/BOMTab.tsx b/web/src/components/items/BOMTab.tsx new file mode 100644 index 0000000..4ef502a --- /dev/null +++ b/web/src/components/items/BOMTab.tsx @@ -0,0 +1,238 @@ +import { useState, useEffect, useCallback } from 'react'; +import { get, post, put, del } from '../../api/client'; +import type { BOMEntry } from '../../api/types'; + +interface BOMTabProps { + partNumber: string; + isEditor: boolean; +} + +interface BOMFormData { + child_part_number: string; + quantity: string; + source: string; + seller_description: string; + unit_cost: string; + sourcing_link: string; +} + +const emptyForm: BOMFormData = { child_part_number: '', quantity: '1', source: '', seller_description: '', unit_cost: '', sourcing_link: '' }; + +export function BOMTab({ partNumber, isEditor }: BOMTabProps) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [showAdd, setShowAdd] = useState(false); + const [editIdx, setEditIdx] = useState(null); + const [form, setForm] = useState(emptyForm); + + const load = useCallback(() => { + setLoading(true); + get(`/api/items/${encodeURIComponent(partNumber)}/bom`) + .then(setEntries) + .catch(() => setEntries([])) + .finally(() => setLoading(false)); + }, [partNumber]); + + useEffect(load, [load]); + + const meta = (e: BOMEntry) => (e.metadata ?? {}) as Record; + const unitCost = (e: BOMEntry) => Number(meta(e).unit_cost) || 0; + const extCost = (e: BOMEntry) => unitCost(e) * (e.quantity ?? 0); + const totalCost = entries.reduce((sum, e) => sum + extCost(e), 0); + + const formToRequest = () => ({ + child_part_number: form.child_part_number, + rel_type: 'component' as const, + quantity: Number(form.quantity) || 1, + metadata: { + source: form.source, + seller_description: form.seller_description, + unit_cost: form.unit_cost, + sourcing_link: form.sourcing_link, + }, + }); + + const handleAdd = async () => { + try { + await post(`/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest()); + setShowAdd(false); + setForm(emptyForm); + load(); + } catch (e) { + alert(e instanceof Error ? e.message : 'Failed to add BOM entry'); + } + }; + + const handleEdit = async (childPN: string) => { + try { + const { child_part_number: _, ...req } = formToRequest(); + await put(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`, req); + setEditIdx(null); + setForm(emptyForm); + load(); + } catch (e) { + alert(e instanceof Error ? e.message : 'Failed to update BOM entry'); + } + }; + + const handleDelete = async (childPN: string) => { + if (!confirm(`Remove ${childPN} from BOM?`)) return; + try { + await del(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`); + load(); + } catch (e) { + alert(e instanceof Error ? e.message : 'Failed to delete BOM entry'); + } + }; + + const startEdit = (idx: number) => { + const e = entries[idx]!; + const m = meta(e); + setForm({ + child_part_number: e.child_part_number, + quantity: String(e.quantity ?? 1), + source: m.source ?? '', + seller_description: m.seller_description ?? '', + unit_cost: m.unit_cost ?? '', + sourcing_link: m.sourcing_link ?? '', + }); + setEditIdx(idx); + setShowAdd(false); + }; + + const inputStyle: React.CSSProperties = { + padding: '0.2rem 0.4rem', fontSize: '0.8rem', + backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)', + borderRadius: '0.3rem', color: 'var(--ctp-text)', width: '100%', + }; + + const formRow = (isEditing: boolean, childPN?: string) => ( + + + setForm({ ...form, child_part_number: e.target.value })} + disabled={isEditing} placeholder="Part number" style={inputStyle} /> + + + setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} /> + + + setForm({ ...form, seller_description: e.target.value })} placeholder="Description" style={inputStyle} /> + + + setForm({ ...form, unit_cost: e.target.value })} type="number" step="0.01" placeholder="0.00" style={inputStyle} /> + + + setForm({ ...form, quantity: e.target.value })} type="number" step="1" placeholder="1" style={{ ...inputStyle, width: 50 }} /> + + — + + setForm({ ...form, sourcing_link: e.target.value })} placeholder="URL" style={inputStyle} /> + + + + + + + ); + + if (loading) return
Loading BOM...
; + + return ( +
+ {/* Toolbar */} +
+ {entries.length} entries + + + {isEditor && ( + + )} +
+ +
+ + + + + + + + + + + {isEditor && } + + + + {showAdd && formRow(false)} + {entries.map((e, idx) => { + if (editIdx === idx) return formRow(true, e.child_part_number); + const m = meta(e); + return ( + + + + + + + + + {isEditor && ( + + )} + + ); + })} + + {totalCost > 0 && ( + + + + + + + )} +
PNSourceSeller DescUnit CostQTYExt CostLinkActions
{e.child_part_number}{m.source ?? ''}{e.child_description || m.seller_description || ''}{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}{e.quantity ?? '—'}{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'} + {m.sourcing_link ? Link : '—'} + + + +
Total:${totalCost.toFixed(2)} +
+
+
+ ); +} + +const thStyle: React.CSSProperties = { + padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)', + color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap', +}; + +const tdStyle: React.CSSProperties = { + padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap', +}; + +const toolBtnStyle: React.CSSProperties = { + padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', + backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer', +}; + +const actionBtnStyle: React.CSSProperties = { + background: 'none', border: 'none', color: 'var(--ctp-subtext1)', cursor: 'pointer', fontSize: '0.75rem', padding: '0.1rem 0.3rem', +}; + +const saveBtnStyle: React.CSSProperties = { + padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem', + backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', marginRight: '0.25rem', +}; + +const cancelBtnStyle: React.CSSProperties = { + padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem', + backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-subtext1)', cursor: 'pointer', +}; diff --git a/web/src/components/items/CreateItemPane.tsx b/web/src/components/items/CreateItemPane.tsx new file mode 100644 index 0000000..dbfad01 --- /dev/null +++ b/web/src/components/items/CreateItemPane.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect } from 'react'; +import { get, post } from '../../api/client'; +import type { Schema, Project } from '../../api/types'; + +interface CreateItemPaneProps { + onCreated: (partNumber: string) => void; + onCancel: () => void; +} + +export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) { + const [schema, setSchema] = useState(null); + const [projects, setProjects] = useState([]); + const [category, setCategory] = useState(''); + const [description, setDescription] = useState(''); + const [sourcingType, setSourcingType] = useState('manufactured'); + const [sourcingLink, setSourcingLink] = useState(''); + const [longDescription, setLongDescription] = useState(''); + const [standardCost, setStandardCost] = useState(''); + const [selectedProjects, setSelectedProjects] = useState([]); + const [catProps, setCatProps] = useState>({}); + const [catPropDefs, setCatPropDefs] = useState>({}); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + get('/api/schemas/kindred-rd').then(setSchema).catch(() => {}); + get('/api/projects').then(setProjects).catch(() => {}); + }, []); + + useEffect(() => { + if (!category) { setCatPropDefs({}); setCatProps({}); return; } + get>(`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`) + .then((defs) => { + setCatPropDefs(defs); + const defaults: Record = {}; + for (const key of Object.keys(defs)) defaults[key] = ''; + setCatProps(defaults); + }) + .catch(() => { setCatPropDefs({}); setCatProps({}); }); + }, [category]); + + const categories = schema?.segments.find((s) => s.name === 'category')?.values ?? {}; + + const handleSubmit = async () => { + if (!category) { setError('Category is required'); return; } + setSaving(true); + setError(null); + + const properties: Record = {}; + for (const [k, v] of Object.entries(catProps)) { + if (!v) continue; + const def = catPropDefs[k]; + if (def?.type === 'number') properties[k] = Number(v); + else if (def?.type === 'boolean') properties[k] = v === 'true'; + else properties[k] = v; + } + + try { + const result = await post<{ part_number: string }>('/api/items', { + schema: 'kindred-rd', + category, + description, + projects: selectedProjects.length > 0 ? selectedProjects : undefined, + properties: Object.keys(properties).length > 0 ? properties : undefined, + sourcing_type: sourcingType || undefined, + sourcing_link: sourcingLink || undefined, + long_description: longDescription || undefined, + standard_cost: standardCost ? Number(standardCost) : undefined, + }); + onCreated(result.part_number); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to create item'); + } finally { + setSaving(false); + } + }; + + const toggleProject = (code: string) => { + setSelectedProjects((prev) => + prev.includes(code) ? prev.filter((p) => p !== code) : [...prev, code] + ); + }; + + return ( +
+ {/* Header */} +
+ New Item + + + +
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + + + + + + + setDescription(e.target.value)} style={inputStyle} placeholder="Item description" /> + + + + + + + + setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" /> + + + + setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" /> + + + +