Files
silo/web/src/components/items/ItemsToolbar.tsx
Zoe Forbes 43ff56fb60 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.
2026-02-06 17:21:18 -06:00

153 lines
4.4 KiB
TypeScript

import { useEffect, useState } from 'react';
import { get } from '../../api/client';
import type { Project } from '../../api/types';
import type { ItemFilters } from '../../hooks/useItems';
interface ItemsToolbarProps {
filters: ItemFilters;
onFilterChange: (partial: Partial<ItemFilters>) => void;
layout: 'horizontal' | 'vertical';
onLayoutChange: (layout: 'horizontal' | 'vertical') => void;
onExport: () => void;
onImport: () => void;
onCreate: () => void;
isEditor: boolean;
}
export function ItemsToolbar({
filters, onFilterChange, layout, onLayoutChange,
onExport, onImport, onCreate, isEditor,
}: ItemsToolbarProps) {
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
get<Project[]>('/api/projects').then(setProjects).catch(() => {});
}, []);
const scopeBtn = (scope: ItemFilters['searchScope'], label: string) => (
<button
onClick={() => onFilterChange({ searchScope: scope })}
style={{
padding: '0.3rem 0.6rem',
fontSize: '0.8rem',
border: 'none',
borderRadius: '0.3rem',
cursor: 'pointer',
backgroundColor: filters.searchScope === scope ? 'var(--ctp-mauve)' : 'var(--ctp-surface1)',
color: filters.searchScope === scope ? 'var(--ctp-crust)' : 'var(--ctp-subtext1)',
}}
>
{label}
</button>
);
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
padding: '0.75rem 0',
borderBottom: '1px solid var(--ctp-surface0)',
marginBottom: '0.5rem',
}}>
{/* Search */}
<input
type="text"
placeholder="Search items... (Ctrl+F)"
value={filters.search}
onChange={(e) => onFilterChange({ search: e.target.value })}
style={{
flex: 1,
minWidth: 200,
padding: '0.4rem 0.75rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.4rem',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
}}
/>
{/* Search scope */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{scopeBtn('all', 'All')}
{scopeBtn('part_number', 'PN')}
{scopeBtn('description', 'Desc')}
</div>
{/* Type filter */}
<select
value={filters.type}
onChange={(e) => onFilterChange({ type: e.target.value })}
style={selectStyle}
>
<option value="">All Types</option>
<option value="part">Part</option>
<option value="assembly">Assembly</option>
<option value="document">Document</option>
<option value="tooling">Tooling</option>
</select>
{/* Project filter */}
<select
value={filters.project}
onChange={(e) => onFilterChange({ project: e.target.value })}
style={selectStyle}
>
<option value="">All Projects</option>
{projects.map((p) => (
<option key={p.code} value={p.code}>{p.code}{p.name ? `${p.name}` : ''}</option>
))}
</select>
{/* Layout toggle */}
<button
onClick={() => onLayoutChange(layout === 'horizontal' ? 'vertical' : 'horizontal')}
title={`Switch to ${layout === 'horizontal' ? 'vertical' : 'horizontal'} layout`}
style={toolBtnStyle}
>
{layout === 'horizontal' ? '⬌' : '⬍'}
</button>
{/* Export */}
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">Export</button>
{/* Import (editor only) */}
{isEditor && (
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">Import</button>
)}
{/* Create (editor only) */}
{isEditor && (
<button onClick={onCreate} style={{
...toolBtnStyle,
backgroundColor: 'var(--ctp-mauve)',
color: 'var(--ctp-crust)',
}}>
+ New
</button>
)}
</div>
);
}
const selectStyle: React.CSSProperties = {
padding: '0.4rem 0.6rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.4rem',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
};
const toolBtnStyle: React.CSSProperties = {
padding: '0.4rem 0.75rem',
backgroundColor: 'var(--ctp-surface1)',
border: 'none',
borderRadius: '0.4rem',
color: 'var(--ctp-text)',
fontSize: '0.85rem',
cursor: 'pointer',
};