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.
This commit is contained in:
Zoe Forbes
2026-02-06 17:21:18 -06:00
parent 78ae2c9783
commit 43ff56fb60
19 changed files with 2846 additions and 58 deletions

View File

@@ -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<string, unknown>;
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<string, unknown>;
comment?: string;
sourcing_type?: string;
sourcing_link?: string;
long_description?: string;
standard_cost?: number;
}
export interface CreateRevisionRequest {
properties: Record<string, unknown>;
comment: string;
}
export interface AddBOMEntryRequest {
child_part_number: string;
rel_type?: string;
quantity?: number;
unit?: string;
reference_designators?: string[];
child_revision?: number;
metadata?: Record<string, unknown>;
}
export interface UpdateBOMEntryRequest {
rel_type?: string;
quantity?: number;
unit?: string;
reference_designators?: string[];
child_revision?: number;
metadata?: Record<string, unknown>;
}
// Schema properties
export interface PropertyDef {
type: string;
required?: boolean;
description?: string;
default?: unknown;
}
export type PropertySchema = Record<string, PropertyDef>;
// Revision comparison
export interface RevisionComparison {
from: number;
to: number;
added: Record<string, unknown>;
removed: Record<string, unknown>;
changed: Record<string, { from: unknown; to: unknown }>;
status_changed?: { from: string; to: string };
file_changed?: boolean;
}

View File

@@ -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<HTMLDivElement>(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 (
<div ref={ref} style={style}>
{items.map((item, i) =>
item.divider ? (
<div key={i} style={{ borderTop: '1px solid var(--ctp-surface1)', margin: '0.25rem 0' }} />
) : (
<button
key={i}
onClick={() => {
if (item.onToggle) item.onToggle();
else if (item.onClick) { item.onClick(); onClose(); }
}}
disabled={item.disabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.35rem 0.75rem',
background: 'none',
border: 'none',
color: item.disabled ? 'var(--ctp-overlay0)' : 'var(--ctp-text)',
fontSize: '0.85rem',
cursor: item.disabled ? 'default' : 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => {
if (!item.disabled) e.currentTarget.style.backgroundColor = 'var(--ctp-surface1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{item.checked !== undefined && (
<span style={{
width: 16, height: 16, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
border: '1px solid var(--ctp-overlay0)', borderRadius: 3,
backgroundColor: item.checked ? 'var(--ctp-mauve)' : 'transparent',
color: item.checked ? 'var(--ctp-crust)' : 'transparent',
fontSize: '0.7rem', fontWeight: 700, flexShrink: 0,
}}>
{item.checked ? '✓' : ''}
</span>
)}
{item.label}
</button>
),
)}
</div>
);
}

View File

@@ -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<BOMEntry[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [editIdx, setEditIdx] = useState<number | null>(null);
const [form, setForm] = useState<BOMFormData>(emptyForm);
const load = useCallback(() => {
setLoading(true);
get<BOMEntry[]>(`/api/items/${encodeURIComponent(partNumber)}/bom`)
.then(setEntries)
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [partNumber]);
useEffect(load, [load]);
const meta = (e: BOMEntry) => (e.metadata ?? {}) as Record<string, string>;
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) => (
<tr style={{ backgroundColor: 'var(--ctp-surface0)' }}>
<td style={tdStyle}>
<input value={form.child_part_number} onChange={(e) => setForm({ ...form, child_part_number: e.target.value })}
disabled={isEditing} placeholder="Part number" style={inputStyle} />
</td>
<td style={tdStyle}>
<input value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} />
</td>
<td style={tdStyle}>
<input value={form.seller_description} onChange={(e) => setForm({ ...form, seller_description: e.target.value })} placeholder="Description" style={inputStyle} />
</td>
<td style={tdStyle}>
<input value={form.unit_cost} onChange={(e) => setForm({ ...form, unit_cost: e.target.value })} type="number" step="0.01" placeholder="0.00" style={inputStyle} />
</td>
<td style={tdStyle}>
<input value={form.quantity} onChange={(e) => setForm({ ...form, quantity: e.target.value })} type="number" step="1" placeholder="1" style={{ ...inputStyle, width: 50 }} />
</td>
<td style={tdStyle}></td>
<td style={tdStyle}>
<input value={form.sourcing_link} onChange={(e) => setForm({ ...form, sourcing_link: e.target.value })} placeholder="URL" style={inputStyle} />
</td>
<td style={tdStyle}>
<button onClick={() => isEditing ? void handleEdit(childPN!) : void handleAdd()} style={saveBtnStyle}>Save</button>
<button onClick={() => { isEditing ? setEditIdx(null) : setShowAdd(false); setForm(emptyForm); }} style={cancelBtnStyle}>Cancel</button>
</td>
</tr>
);
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading BOM...</div>;
return (
<div>
{/* Toolbar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--ctp-subtext1)' }}>{entries.length} entries</span>
<span style={{ flex: 1 }} />
<button
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`; }}
style={toolBtnStyle}
>
Export CSV
</button>
{isEditor && (
<button onClick={() => { setShowAdd(true); setEditIdx(null); setForm(emptyForm); }} style={toolBtnStyle}>+ Add</button>
)}
</div>
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={thStyle}>PN</th>
<th style={thStyle}>Source</th>
<th style={thStyle}>Seller Desc</th>
<th style={thStyle}>Unit Cost</th>
<th style={thStyle}>QTY</th>
<th style={thStyle}>Ext Cost</th>
<th style={thStyle}>Link</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{showAdd && formRow(false)}
{entries.map((e, idx) => {
if (editIdx === idx) return formRow(true, e.child_part_number);
const m = meta(e);
return (
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{e.child_part_number}</td>
<td style={tdStyle}>{m.source ?? ''}</td>
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.child_description || m.seller_description || ''}</td>
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}</td>
<td style={tdStyle}>{e.quantity ?? '—'}</td>
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}</td>
<td style={tdStyle}>
{m.sourcing_link ? <a href={m.sourcing_link} target="_blank" rel="noreferrer" style={{ color: 'var(--ctp-sapphire)', fontSize: '0.75rem' }}>Link</a> : '—'}
</td>
{isEditor && (
<td style={tdStyle}>
<button onClick={() => startEdit(idx)} style={actionBtnStyle}>Edit</button>
<button onClick={() => void handleDelete(e.child_part_number)} style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}>Del</button>
</td>
)}
</tr>
);
})}
</tbody>
{totalCost > 0 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--ctp-surface1)' }}>
<td colSpan={5} style={{ ...tdStyle, textAlign: 'right', fontWeight: 600 }}>Total:</td>
<td style={{ ...tdStyle, fontFamily: 'monospace', fontWeight: 600 }}>${totalCost.toFixed(2)}</td>
<td colSpan={isEditor ? 2 : 1} style={tdStyle} />
</tr>
</tfoot>
)}
</table>
</div>
</div>
);
}
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',
};

View File

@@ -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<Schema | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
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<string[]>([]);
const [catProps, setCatProps] = useState<Record<string, string>>({});
const [catPropDefs, setCatPropDefs] = useState<Record<string, { type: string }>>({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Schema>('/api/schemas/kindred-rd').then(setSchema).catch(() => {});
get<Project[]>('/api/projects').then(setProjects).catch(() => {});
}, []);
useEffect(() => {
if (!category) { setCatPropDefs({}); setCatProps({}); return; }
get<Record<string, { type: string }>>(`/api/schemas/kindred-rd/properties?category=${encodeURIComponent(category)}`)
.then((defs) => {
setCatPropDefs(defs);
const defaults: Record<string, string> = {};
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<string, unknown> = {};
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid var(--ctp-surface1)',
backgroundColor: 'var(--ctp-mantle)',
flexShrink: 0,
}}>
<span style={{ color: 'var(--ctp-green)', fontWeight: 600, fontSize: '0.9rem' }}>New Item</span>
<span style={{ flex: 1 }} />
<button onClick={() => void handleSubmit()} disabled={saving} style={{
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: saving ? 0.6 : 1,
}}>
{saving ? 'Creating...' : 'Create'}
</button>
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
</div>
{/* Form */}
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
{error && (
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem', borderRadius: '0.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
{error}
</div>
)}
<FormGroup label="Category *">
<select value={category} onChange={(e) => setCategory(e.target.value)} style={inputStyle}>
<option value="">Select category...</option>
{Object.entries(categories).map(([code, name]) => (
<option key={code} value={code}>{code} {name}</option>
))}
</select>
</FormGroup>
<FormGroup label="Description">
<input value={description} onChange={(e) => setDescription(e.target.value)} style={inputStyle} placeholder="Item description" />
</FormGroup>
<FormGroup label="Sourcing Type">
<select value={sourcingType} onChange={(e) => setSourcingType(e.target.value)} style={inputStyle}>
<option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option>
<option value="outsourced">Outsourced</option>
</select>
</FormGroup>
<FormGroup label="Sourcing Link">
<input value={sourcingLink} onChange={(e) => setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" />
</FormGroup>
<FormGroup label="Standard Cost">
<input type="number" step="0.01" value={standardCost} onChange={(e) => setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" />
</FormGroup>
<FormGroup label="Long Description">
<textarea value={longDescription} onChange={(e) => setLongDescription(e.target.value)} style={{ ...inputStyle, minHeight: 60, resize: 'vertical' }} placeholder="Detailed description..." />
</FormGroup>
{/* Project tags */}
<FormGroup label="Projects">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{projects.map((p) => (
<button
key={p.code}
onClick={() => toggleProject(p.code)}
style={{
padding: '0.15rem 0.5rem', fontSize: '0.75rem', border: 'none', borderRadius: '1rem', cursor: 'pointer',
backgroundColor: selectedProjects.includes(p.code) ? 'rgba(203,166,247,0.3)' : 'var(--ctp-surface0)',
color: selectedProjects.includes(p.code) ? 'var(--ctp-mauve)' : 'var(--ctp-subtext0)',
}}
>
{p.code}
</button>
))}
</div>
</FormGroup>
{/* Category properties */}
{Object.keys(catPropDefs).length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--ctp-surface1)', margin: '0.75rem 0 0.5rem', paddingTop: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--ctp-subtext0)', fontWeight: 600 }}>Category Properties</span>
</div>
{Object.entries(catPropDefs).map(([key, def]) => (
<FormGroup key={key} label={key}>
{def.type === 'boolean' ? (
<select value={catProps[key] ?? ''} onChange={(e) => setCatProps({ ...catProps, [key]: e.target.value })} style={inputStyle}>
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
type={def.type === 'number' ? 'number' : 'text'}
value={catProps[key] ?? ''}
onChange={(e) => setCatProps({ ...catProps, [key]: e.target.value })}
style={inputStyle}
/>
)}
</FormGroup>
))}
</>
)}
</div>
</div>
);
}
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: '0.6rem' }}>
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label>
{children}
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem',
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
};
const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
};

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { del } from '../../api/client';
interface DeleteItemPaneProps {
partNumber: string;
onDeleted: () => void;
onCancel: () => void;
}
export function DeleteItemPane({ partNumber, onDeleted, onCancel }: DeleteItemPaneProps) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
setDeleting(true);
setError(null);
try {
await del(`/api/items/${encodeURIComponent(partNumber)}`);
onDeleted();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete item');
} finally {
setDeleting(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid var(--ctp-surface1)',
backgroundColor: 'var(--ctp-mantle)',
flexShrink: 0,
}}>
<span style={{ color: 'var(--ctp-red)', fontWeight: 600, fontSize: '0.9rem' }}>Delete Item</span>
<span style={{ flex: 1 }} />
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem', gap: '1rem' }}>
{error && (
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem 1rem', borderRadius: '0.3rem', fontSize: '0.85rem', width: '100%', textAlign: 'center' }}>
{error}
</div>
)}
<div style={{ textAlign: 'center' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--ctp-text)', marginBottom: '0.5rem' }}>
Permanently delete item
</p>
<p style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)', fontSize: '1.1rem', fontWeight: 600 }}>
{partNumber}
</p>
</div>
<p style={{ color: 'var(--ctp-subtext0)', fontSize: '0.85rem', textAlign: 'center', maxWidth: 300 }}>
This will permanently remove this item, all its revisions, BOM entries, and file attachments. This action cannot be undone.
</p>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
<button onClick={onCancel} style={{
padding: '0.5rem 1.25rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.4rem',
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer',
}}>
Cancel
</button>
<button onClick={() => void handleDelete()} disabled={deleting} style={{
padding: '0.5rem 1.25rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.4rem',
backgroundColor: 'var(--ctp-red)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: deleting ? 0.6 : 1,
}}>
{deleting ? 'Deleting...' : 'Delete Permanently'}
</button>
</div>
</div>
</div>
);
}
const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
};

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { get, put } from '../../api/client';
import type { Item } from '../../api/types';
interface EditItemPaneProps {
partNumber: string;
onSaved: () => void;
onCancel: () => void;
}
export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pn, setPN] = useState('');
const [itemType, setItemType] = useState('');
const [description, setDescription] = useState('');
const [sourcingType, setSourcingType] = useState('');
const [sourcingLink, setSourcingLink] = useState('');
const [longDescription, setLongDescription] = useState('');
const [standardCost, setStandardCost] = useState('');
useEffect(() => {
setLoading(true);
get<Item>(`/api/items/${encodeURIComponent(partNumber)}`)
.then((item) => {
setPN(item.part_number);
setItemType(item.item_type);
setDescription(item.description);
setSourcingType(item.sourcing_type ?? '');
setSourcingLink(item.sourcing_link ?? '');
setLongDescription(item.long_description ?? '');
setStandardCost(item.standard_cost != null ? String(item.standard_cost) : '');
})
.catch(() => setError('Failed to load item'))
.finally(() => setLoading(false));
}, [partNumber]);
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await put(`/api/items/${encodeURIComponent(partNumber)}`, {
part_number: pn !== partNumber ? pn : undefined,
item_type: itemType || undefined,
description: description || undefined,
sourcing_type: sourcingType || undefined,
sourcing_link: sourcingLink || undefined,
long_description: longDescription || undefined,
standard_cost: standardCost ? Number(standardCost) : undefined,
});
onSaved();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save item');
} finally {
setSaving(false);
}
};
if (loading) return <div style={{ padding: '1rem', color: 'var(--ctp-subtext0)' }}>Loading...</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid var(--ctp-surface1)',
backgroundColor: 'var(--ctp-mantle)',
flexShrink: 0,
}}>
<span style={{ color: 'var(--ctp-blue)', fontWeight: 600, fontSize: '0.9rem' }}>Edit {partNumber}</span>
<span style={{ flex: 1 }} />
<button onClick={() => void handleSave()} disabled={saving} style={{
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-blue)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: saving ? 0.6 : 1,
}}>
{saving ? 'Saving...' : 'Save'}
</button>
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
{error && (
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem', borderRadius: '0.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
{error}
</div>
)}
<FormGroup label="Part Number">
<input value={pn} onChange={(e) => setPN(e.target.value)} style={inputStyle} />
</FormGroup>
<FormGroup label="Type">
<select value={itemType} onChange={(e) => setItemType(e.target.value)} style={inputStyle}>
<option value="part">Part</option>
<option value="assembly">Assembly</option>
<option value="document">Document</option>
<option value="tooling">Tooling</option>
</select>
</FormGroup>
<FormGroup label="Description">
<input value={description} onChange={(e) => setDescription(e.target.value)} style={inputStyle} />
</FormGroup>
<FormGroup label="Sourcing Type">
<select value={sourcingType} onChange={(e) => setSourcingType(e.target.value)} style={inputStyle}>
<option value=""></option>
<option value="manufactured">Manufactured</option>
<option value="purchased">Purchased</option>
<option value="outsourced">Outsourced</option>
</select>
</FormGroup>
<FormGroup label="Sourcing Link">
<input value={sourcingLink} onChange={(e) => setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" />
</FormGroup>
<FormGroup label="Standard Cost">
<input type="number" step="0.01" value={standardCost} onChange={(e) => setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" />
</FormGroup>
<FormGroup label="Long Description">
<textarea value={longDescription} onChange={(e) => setLongDescription(e.target.value)} style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }} />
</FormGroup>
</div>
</div>
);
}
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: '0.6rem' }}>
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label>
{children}
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem',
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
};
const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
};

View File

@@ -0,0 +1,36 @@
import type { Item } from '../../api/types';
interface FooterStatsProps {
items: Item[];
}
export function FooterStats({ items }: FooterStatsProps) {
const total = items.length;
const parts = items.filter((i) => i.item_type === 'part').length;
const assemblies = items.filter((i) => i.item_type === 'assembly').length;
const documents = items.filter((i) => i.item_type === 'document').length;
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 28,
backgroundColor: 'var(--ctp-surface0)',
borderTop: '1px solid var(--ctp-surface1)',
display: 'flex',
alignItems: 'center',
padding: '0 2rem',
gap: '2rem',
fontSize: '0.75rem',
color: 'var(--ctp-subtext0)',
zIndex: 100,
}}>
<span>Total: <strong style={{ color: 'var(--ctp-text)' }}>{total}</strong></span>
<span>Parts: <strong style={{ color: 'var(--ctp-blue)' }}>{parts}</strong></span>
<span>Assemblies: <strong style={{ color: 'var(--ctp-green)' }}>{assemblies}</strong></span>
<span>Documents: <strong style={{ color: 'var(--ctp-yellow)' }}>{documents}</strong></span>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useState, useRef } from 'react';
import type { CSVImportResult } from '../../api/types';
interface ImportItemsPaneProps {
onImported: () => void;
onCancel: () => void;
}
export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps) {
const [file, setFile] = useState<File | null>(null);
const [skipExisting, setSkipExisting] = useState(false);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<CSVImportResult | null>(null);
const [validated, setValidated] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const doImport = async (dryRun: boolean) => {
if (!file) return;
setImporting(true);
setError(null);
const formData = new FormData();
formData.append('file', file);
if (dryRun) formData.append('dry_run', 'true');
if (skipExisting) formData.append('skip_existing', 'true');
try {
const res = await fetch('/api/items/import', {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json() as CSVImportResult;
if (!res.ok) {
setError((data as unknown as { message?: string }).message ?? `HTTP ${res.status}`);
} else {
setResult(data);
if (dryRun) {
setValidated(true);
} else {
onImported();
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Import failed');
} finally {
setImporting(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
display: 'flex', alignItems: 'center', gap: '0.75rem',
padding: '0.5rem 0.75rem',
borderBottom: '1px solid var(--ctp-surface1)',
backgroundColor: 'var(--ctp-mantle)',
flexShrink: 0,
}}>
<span style={{ color: 'var(--ctp-yellow)', fontWeight: 600, fontSize: '0.9rem' }}>Import Items (CSV)</span>
<span style={{ flex: 1 }} />
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
{error && (
<div style={{ color: 'var(--ctp-red)', backgroundColor: 'rgba(243,139,168,0.1)', padding: '0.5rem', borderRadius: '0.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
{error}
</div>
)}
{/* Instructions */}
<div style={{ fontSize: '0.8rem', color: 'var(--ctp-subtext0)', marginBottom: '0.75rem' }}>
<p style={{ marginBottom: '0.25rem' }}>Upload a CSV file with items to import.</p>
<p>Required column: <strong style={{ color: 'var(--ctp-text)' }}>category</strong></p>
<p>Optional: description, projects, sourcing_type, sourcing_link, long_description, standard_cost, + property columns</p>
<a
href="/api/items/template.csv"
style={{ color: 'var(--ctp-sapphire)', fontSize: '0.8rem' }}
>
Download CSV template
</a>
</div>
{/* File input */}
<div style={{ marginBottom: '0.75rem' }}>
<input
ref={fileRef}
type="file"
accept=".csv"
onChange={(e) => {
setFile(e.target.files?.[0] ?? null);
setResult(null);
setValidated(false);
}}
style={{ display: 'none' }}
/>
<button
onClick={() => fileRef.current?.click()}
style={{
padding: '0.75rem 1.5rem', border: '2px dashed var(--ctp-surface2)',
borderRadius: '0.5rem', backgroundColor: 'var(--ctp-surface0)',
color: 'var(--ctp-subtext1)', cursor: 'pointer', width: '100%',
fontSize: '0.85rem',
}}
>
{file ? file.name : 'Choose CSV file...'}
</button>
</div>
{/* Options */}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--ctp-subtext1)', marginBottom: '0.75rem' }}>
<input type="checkbox" checked={skipExisting} onChange={(e) => setSkipExisting(e.target.checked)} />
Skip existing items
</label>
{/* Actions */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}>
{!validated ? (
<button
onClick={() => void doImport(true)}
disabled={!file || importing}
style={{
padding: '0.4rem 0.75rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-yellow)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: (!file || importing) ? 0.5 : 1,
}}
>
{importing ? 'Validating...' : 'Validate (Dry Run)'}
</button>
) : (
<button
onClick={() => void doImport(false)}
disabled={importing || (result?.error_count ?? 0) > 0}
style={{
padding: '0.4rem 0.75rem', fontSize: '0.85rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: (importing || (result?.error_count ?? 0) > 0) ? 0.5 : 1,
}}
>
{importing ? 'Importing...' : 'Import Now'}
</button>
)}
</div>
{/* Results */}
{result && (
<div style={{ padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem', fontSize: '0.8rem' }}>
<p>Total rows: <strong>{result.total_rows}</strong></p>
<p>Success: <strong style={{ color: 'var(--ctp-green)' }}>{result.success_count}</strong></p>
{result.error_count > 0 && (
<p>Errors: <strong style={{ color: 'var(--ctp-red)' }}>{result.error_count}</strong></p>
)}
{result.errors && result.errors.length > 0 && (
<div style={{ marginTop: '0.5rem', maxHeight: 200, overflow: 'auto' }}>
{result.errors.map((err, i) => (
<div key={i} style={{ color: 'var(--ctp-red)', fontSize: '0.75rem', padding: '0.1rem 0' }}>
Row {err.row}{err.field ? ` [${err.field}]` : ''}: {err.message}
</div>
))}
</div>
)}
{result.created_items && result.created_items.length > 0 && (
<div style={{ marginTop: '0.5rem', color: 'var(--ctp-green)', fontSize: '0.75rem' }}>
Created: {result.created_items.join(', ')}
</div>
)}
</div>
)}
</div>
</div>
);
}
const headerBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
};

View File

@@ -0,0 +1,202 @@
import { useState, useEffect } from "react";
import { get } from "../../api/client";
import type { Item } from "../../api/types";
import { MainTab } from "./MainTab";
import { PropertiesTab } from "./PropertiesTab";
import { RevisionsTab } from "./RevisionsTab";
import { BOMTab } from "./BOMTab";
import { WhereUsedTab } from "./WhereUsedTab";
type Tab = "main" | "properties" | "revisions" | "bom" | "where-used";
interface ItemDetailProps {
partNumber: string;
onClose: () => void;
onEdit: (pn: string) => void;
onDelete: (pn: string) => void;
onReload: () => void;
isEditor: boolean;
}
const tabs: { key: Tab; label: string }[] = [
{ key: "main", label: "Main" },
{ key: "properties", label: "Properties" },
{ key: "revisions", label: "Revisions" },
{ key: "bom", label: "BOM" },
{ key: "where-used", label: "Where Used" },
];
export function ItemDetail({
partNumber,
onClose,
onEdit,
onDelete,
onReload,
isEditor,
}: ItemDetailProps) {
const [item, setItem] = useState<Item | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>("main");
useEffect(() => {
setLoading(true);
setActiveTab("main");
get<Item>(`/api/items/${encodeURIComponent(partNumber)}?include=properties`)
.then(setItem)
.catch(() => setItem(null))
.finally(() => setLoading(false));
}, [partNumber]);
if (loading) {
return (
<div style={{ padding: "1rem", color: "var(--ctp-subtext0)" }}>
Loading...
</div>
);
}
if (!item) {
return (
<div style={{ padding: "1rem", color: "var(--ctp-red)" }}>
Item not found
</div>
);
}
const typeColors: Record<string, { bg: string; color: string }> = {
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
};
const tc = typeColors[item.item_type] ?? {
bg: "var(--ctp-surface1)",
color: "var(--ctp-text)",
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
}}
>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
fontWeight: 600,
fontSize: "0.9rem",
}}
>
{item.part_number}
</span>
<span
style={{
padding: "0.1rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.7rem",
fontWeight: 500,
backgroundColor: tc.bg,
color: tc.color,
}}
>
{item.item_type}
</span>
<span style={{ flex: 1 }} />
{isEditor && (
<>
<button
onClick={() => onEdit(item.part_number)}
style={headerBtnStyle}
>
Edit
</button>
<button
onClick={() => onDelete(item.part_number)}
style={{ ...headerBtnStyle, color: "var(--ctp-red)" }}
>
Delete
</button>
</>
)}
<button
onClick={onClose}
style={{ ...headerBtnStyle, fontSize: "1rem" }}
>
×
</button>
</div>
{/* Tabs */}
<div
style={{
display: "flex",
gap: "0",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
}}
>
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
border: "none",
borderBottom:
activeTab === tab.key
? "2px solid var(--ctp-mauve)"
: "2px solid transparent",
backgroundColor: "transparent",
color:
activeTab === tab.key
? "var(--ctp-mauve)"
: "var(--ctp-subtext0)",
cursor: "pointer",
}}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{activeTab === "main" && (
<MainTab item={item} onReload={onReload} isEditor={isEditor} />
)}
{activeTab === "properties" && (
<PropertiesTab item={item} onReload={onReload} isEditor={isEditor} />
)}
{activeTab === "revisions" && (
<RevisionsTab partNumber={item.part_number} isEditor={isEditor} />
)}
{activeTab === "bom" && (
<BOMTab partNumber={item.part_number} isEditor={isEditor} />
)}
{activeTab === "where-used" && (
<WhereUsedTab partNumber={item.part_number} />
)}
</div>
</div>
);
}
const headerBtnStyle: React.CSSProperties = {
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.8rem",
padding: "0.2rem 0.4rem",
};

View File

@@ -0,0 +1,262 @@
import { useState, useCallback } from 'react';
import type { Item } from '../../api/types';
import { ContextMenu, type ContextMenuItem } from '../ContextMenu';
export interface ColumnDef {
key: string;
label: string;
}
export const ALL_COLUMNS: ColumnDef[] = [
{ key: 'part_number', label: 'Part Number' },
{ key: 'item_type', label: 'Type' },
{ key: 'description', label: 'Description' },
{ key: 'revision', label: 'Rev' },
{ key: 'projects', label: 'Projects' },
{ key: 'created', label: 'Created' },
{ key: 'actions', label: 'Actions' },
];
export const DEFAULT_COLUMNS_H = ['part_number', 'item_type', 'description', 'revision'];
export const DEFAULT_COLUMNS_V = ['part_number', 'item_type', 'description', 'revision', 'created', 'actions'];
interface ItemTableProps {
items: Item[];
loading: boolean;
selectedPN: string | null;
onSelect: (pn: string) => void;
visibleColumns: string[];
onColumnsChange: (cols: string[]) => void;
onEdit?: (pn: string) => void;
onDelete?: (pn: string) => void;
sortKey: string;
sortDir: 'asc' | 'desc';
onSort: (key: string) => void;
}
const typeColors: Record<string, { bg: string; color: string }> = {
part: { bg: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' },
assembly: { bg: 'rgba(166,227,161,0.2)', color: 'var(--ctp-green)' },
document: { bg: 'rgba(249,226,175,0.2)', color: 'var(--ctp-yellow)' },
tooling: { bg: 'rgba(243,139,168,0.2)', color: 'var(--ctp-red)' },
};
function formatDate(s: string) {
if (!s) return '';
const d = new Date(s);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
function copyPN(pn: string) {
void navigator.clipboard.writeText(pn);
}
export function ItemTable({
items, loading, selectedPN, onSelect, visibleColumns, onColumnsChange,
onEdit, onDelete, sortKey, sortDir, onSort,
}: ItemTableProps) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
const handleHeaderContext = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setCtxMenu({ x: e.clientX, y: e.clientY });
}, []);
const toggleColumn = useCallback((key: string) => {
if (key === 'part_number') return; // always visible
const next = visibleColumns.includes(key)
? visibleColumns.filter((c) => c !== key)
: [...visibleColumns, key];
if (next.length > 0) onColumnsChange(next);
}, [visibleColumns, onColumnsChange]);
const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key));
const sortedItems = [...items].sort((a, b) => {
let av: string | number = '';
let bv: string | number = '';
switch (sortKey) {
case 'part_number': av = a.part_number; bv = b.part_number; break;
case 'item_type': av = a.item_type; bv = b.item_type; break;
case 'description': av = a.description; bv = b.description; break;
case 'revision': av = a.current_revision; bv = b.current_revision; break;
case 'created': av = a.created_at; bv = b.created_at; break;
default: return 0;
}
if (av < bv) return sortDir === 'asc' ? -1 : 1;
if (av > bv) return sortDir === 'asc' ? 1 : -1;
return 0;
});
const thStyle: React.CSSProperties = {
padding: '0.35rem 0.75rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
fontWeight: 600,
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
userSelect: 'none',
whiteSpace: 'nowrap',
};
const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.75rem',
fontSize: '0.85rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 300,
};
if (loading) {
return <div style={{ padding: '2rem', color: 'var(--ctp-subtext0)' }}>Loading items...</div>;
}
return (
<>
<div style={{ overflow: 'auto', height: '100%' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead onContextMenu={handleHeaderContext}>
<tr>
{cols.map((col) => (
<th
key={col.key}
style={thStyle}
onClick={() => col.key !== 'actions' && onSort(col.key)}
>
{col.label}
{sortKey === col.key && (
<span style={{ marginLeft: 4 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedItems.map((item, idx) => {
const isSelected = item.part_number === selectedPN;
const rowBg = isSelected
? 'var(--ctp-surface1)'
: idx % 2 === 0
? 'var(--ctp-base)'
: 'var(--ctp-surface0)';
return (
<tr
key={item.id}
onClick={() => onSelect(item.part_number)}
style={{
backgroundColor: rowBg,
cursor: 'pointer',
borderBottom: '1px solid var(--ctp-surface0)',
}}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--ctp-surface0)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = rowBg;
}}
>
{cols.map((col) => {
switch (col.key) {
case 'part_number':
return (
<td key={col.key} style={tdStyle}>
<span
onClick={(e) => { e.stopPropagation(); copyPN(item.part_number); }}
title="Click to copy"
style={{
fontFamily: "'JetBrains Mono', monospace",
color: 'var(--ctp-peach)',
cursor: 'copy',
}}
>
{item.part_number}
</span>
</td>
);
case 'item_type': {
const tc = typeColors[item.item_type] ?? { bg: 'var(--ctp-surface1)', color: 'var(--ctp-text)' };
return (
<td key={col.key} style={tdStyle}>
<span style={{
padding: '0.1rem 0.5rem', borderRadius: '1rem',
fontSize: '0.75rem', fontWeight: 500,
backgroundColor: tc.bg, color: tc.color,
}}>
{item.item_type}
</span>
</td>
);
}
case 'description':
return <td key={col.key} style={{ ...tdStyle, maxWidth: 400 }}>{item.description}</td>;
case 'revision':
return <td key={col.key} style={tdStyle}>Rev {item.current_revision}</td>;
case 'projects':
return <td key={col.key} style={tdStyle}></td>;
case 'created':
return <td key={col.key} style={tdStyle}>{formatDate(item.created_at)}</td>;
case 'actions':
return (
<td key={col.key} style={tdStyle}>
<button
onClick={(e) => { e.stopPropagation(); onEdit?.(item.part_number); }}
style={actionBtnStyle}
>
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete?.(item.part_number); }}
style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}
>
Del
</button>
</td>
);
default:
return <td key={col.key} style={tdStyle} />;
}
})}
</tr>
);
})}
{sortedItems.length === 0 && (
<tr>
<td colSpan={cols.length} style={{ padding: '2rem', textAlign: 'center', color: 'var(--ctp-subtext0)' }}>
No items found
</td>
</tr>
)}
</tbody>
</table>
</div>
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={ALL_COLUMNS.map((col): ContextMenuItem => ({
label: col.label,
checked: visibleColumns.includes(col.key),
onToggle: () => toggleColumn(col.key),
disabled: col.key === 'part_number',
}))}
/>
)}
</>
);
}
const actionBtnStyle: React.CSSProperties = {
background: 'none',
border: 'none',
color: 'var(--ctp-subtext1)',
cursor: 'pointer',
fontSize: '0.8rem',
padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
};

View File

@@ -0,0 +1,152 @@
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',
};

View File

@@ -0,0 +1,169 @@
import { useState, useEffect } from 'react';
import { get, post, del } from '../../api/client';
import type { Item, Project, Revision } from '../../api/types';
interface MainTabProps {
item: Item;
onReload: () => void;
isEditor: boolean;
}
function formatDate(s: string) {
if (!s) return '—';
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(1)} GB`;
}
export function MainTab({ item, onReload, isEditor }: MainTabProps) {
const [itemProjects, setItemProjects] = useState<string[]>([]);
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [latestRev, setLatestRev] = useState<Revision | null>(null);
const [addProject, setAddProject] = useState('');
useEffect(() => {
get<string[]>(`/api/items/${encodeURIComponent(item.part_number)}/projects`)
.then(setItemProjects)
.catch(() => setItemProjects([]));
get<Project[]>('/api/projects')
.then(setAllProjects)
.catch(() => {});
get<Revision[]>(`/api/items/${encodeURIComponent(item.part_number)}/revisions`)
.then((revs) => {
if (revs.length > 0) setLatestRev(revs[revs.length - 1]!);
})
.catch(() => {});
}, [item.part_number]);
const handleAddProject = async () => {
if (!addProject) return;
try {
await post(`/api/items/${encodeURIComponent(item.part_number)}/projects`, { projects: [addProject] });
setItemProjects((prev) => [...prev, addProject]);
setAddProject('');
onReload();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to add project');
}
};
const handleRemoveProject = async (code: string) => {
try {
await del(`/api/items/${encodeURIComponent(item.part_number)}/projects/${encodeURIComponent(code)}`);
setItemProjects((prev) => prev.filter((p) => p !== code));
onReload();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to remove project');
}
};
const row = (label: string, value: React.ReactNode) => (
<div style={{ display: 'flex', gap: '1rem', padding: '0.3rem 0', fontSize: '0.85rem' }}>
<span style={{ width: 120, flexShrink: 0, color: 'var(--ctp-subtext0)' }}>{label}</span>
<span style={{ color: 'var(--ctp-text)' }}>{value}</span>
</div>
);
return (
<div>
{row('Part Number', <span style={{ fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{item.part_number}</span>)}
{row('Description', item.description)}
{row('Type', item.item_type)}
{row('Sourcing', item.sourcing_type || '—')}
{item.sourcing_link && row('Source Link', <a href={item.sourcing_link} target="_blank" rel="noreferrer">{item.sourcing_link}</a>)}
{item.standard_cost != null && row('Std Cost', `$${item.standard_cost.toFixed(2)}`)}
{row('Revision', `Rev ${item.current_revision}`)}
{row('Created', formatDate(item.created_at))}
{row('Updated', formatDate(item.updated_at))}
{item.long_description && (
<div style={{ marginTop: '0.75rem', padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem', fontSize: '0.85rem' }}>
<div style={{ color: 'var(--ctp-subtext0)', fontSize: '0.75rem', marginBottom: '0.25rem' }}>Long Description</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{item.long_description}</div>
</div>
)}
{/* Project Tags */}
<div style={{ marginTop: '0.75rem' }}>
<div style={{ color: 'var(--ctp-subtext0)', fontSize: '0.75rem', marginBottom: '0.25rem' }}>Projects</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', alignItems: 'center' }}>
{itemProjects.map((code) => (
<span key={code} style={{
display: 'inline-flex', alignItems: 'center', gap: '0.25rem',
padding: '0.1rem 0.5rem', borderRadius: '1rem',
backgroundColor: 'rgba(203,166,247,0.15)', color: 'var(--ctp-mauve)',
fontSize: '0.75rem',
}}>
{code}
{isEditor && (
<button
onClick={() => void handleRemoveProject(code)}
style={{ background: 'none', border: 'none', color: 'var(--ctp-overlay0)', cursor: 'pointer', fontSize: '0.8rem', padding: 0 }}
>
×
</button>
)}
</span>
))}
{isEditor && (
<>
<select
value={addProject}
onChange={(e) => setAddProject(e.target.value)}
style={{
padding: '0.1rem 0.3rem', fontSize: '0.75rem',
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
}}
>
<option value="">+</option>
{allProjects
.filter((p) => !itemProjects.includes(p.code))
.map((p) => <option key={p.code} value={p.code}>{p.code}</option>)}
</select>
{addProject && (
<button onClick={() => void handleAddProject()} style={{
padding: '0.1rem 0.4rem', fontSize: '0.7rem', border: 'none',
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)',
borderRadius: '0.3rem', cursor: 'pointer',
}}>
Add
</button>
)}
</>
)}
</div>
</div>
{/* File Info */}
{latestRev?.file_key && (
<div style={{ marginTop: '0.75rem', padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem' }}>
<div style={{ color: 'var(--ctp-subtext0)', fontSize: '0.75rem', marginBottom: '0.25rem' }}>File Attachment (Rev {latestRev.revision_number})</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', fontSize: '0.85rem' }}>
{latestRev.file_size != null && <span>{formatFileSize(latestRev.file_size)}</span>}
{latestRev.file_checksum && (
<span title={latestRev.file_checksum} style={{ color: 'var(--ctp-overlay1)', fontFamily: 'monospace', fontSize: '0.75rem' }}>
SHA256: {latestRev.file_checksum.substring(0, 12)}...
</span>
)}
<button
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`; }}
style={{
padding: '0.2rem 0.5rem', fontSize: '0.8rem', border: 'none',
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)',
borderRadius: '0.3rem', cursor: 'pointer',
}}
>
Download
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState } from 'react';
import { post } from '../../api/client';
import type { Item } from '../../api/types';
interface PropertiesTabProps {
item: Item;
onReload: () => void;
isEditor: boolean;
}
type Mode = 'form' | 'json';
interface PropRow {
key: string;
value: string;
type: 'string' | 'number' | 'boolean';
}
function detectType(v: unknown): PropRow['type'] {
if (typeof v === 'number') return 'number';
if (typeof v === 'boolean') return 'boolean';
return 'string';
}
function toRows(props: Record<string, unknown>): PropRow[] {
return Object.entries(props).map(([key, value]) => ({
key,
value: String(value ?? ''),
type: detectType(value),
}));
}
function fromRows(rows: PropRow[]): Record<string, unknown> {
const obj: Record<string, unknown> = {};
for (const row of rows) {
if (!row.key.trim()) continue;
switch (row.type) {
case 'number': obj[row.key] = Number(row.value) || 0; break;
case 'boolean': obj[row.key] = row.value === 'true'; break;
default: obj[row.key] = row.value;
}
}
return obj;
}
export function PropertiesTab({ item, onReload, isEditor }: PropertiesTabProps) {
const props = item.properties ?? {};
const [mode, setMode] = useState<Mode>('form');
const [rows, setRows] = useState<PropRow[]>(toRows(props));
const [jsonText, setJsonText] = useState(JSON.stringify(props, null, 2));
const [jsonError, setJsonError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const syncFormToJson = () => {
setJsonText(JSON.stringify(fromRows(rows), null, 2));
setJsonError(null);
};
const syncJsonToForm = () => {
try {
const parsed = JSON.parse(jsonText) as Record<string, unknown>;
setRows(toRows(parsed));
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
}
};
const switchMode = (m: Mode) => {
if (m === 'json') syncFormToJson();
else syncJsonToForm();
setMode(m);
};
const updateRow = (idx: number, field: keyof PropRow, value: string) => {
setRows((prev) => prev.map((r, i) => i === idx ? { ...r, [field]: value } : r));
};
const removeRow = (idx: number) => {
setRows((prev) => prev.filter((_, i) => i !== idx));
};
const addRow = () => {
setRows((prev) => [...prev, { key: '', value: '', type: 'string' }]);
};
const handleSave = async () => {
let properties: Record<string, unknown>;
if (mode === 'json') {
try {
properties = JSON.parse(jsonText) as Record<string, unknown>;
} catch {
setJsonError('Invalid JSON');
return;
}
} else {
properties = fromRows(rows);
}
const comment = prompt('Revision comment (optional):') ?? '';
setSaving(true);
try {
await post(`/api/items/${encodeURIComponent(item.part_number)}/revisions`, { properties, comment });
onReload();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to save properties');
} finally {
setSaving(false);
}
};
const inputStyle: React.CSSProperties = {
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
};
return (
<div>
{/* Mode toggle */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem', alignItems: 'center' }}>
<button onClick={() => switchMode('form')} style={mode === 'form' ? activeTabBtn : tabBtn}>Form</button>
<button onClick={() => switchMode('json')} style={mode === 'json' ? activeTabBtn : tabBtn}>JSON</button>
<span style={{ flex: 1 }} />
{isEditor && (
<button onClick={() => void handleSave()} disabled={saving} style={{
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: saving ? 0.6 : 1,
}}>
{saving ? 'Saving...' : 'Save (New Revision)'}
</button>
)}
</div>
{mode === 'form' ? (
<div>
{rows.map((row, idx) => (
<div key={idx} style={{ display: 'flex', gap: '0.3rem', marginBottom: '0.25rem', alignItems: 'center' }}>
<input
value={row.key}
onChange={(e) => updateRow(idx, 'key', e.target.value)}
placeholder="Key"
style={{ ...inputStyle, width: 140 }}
disabled={!isEditor}
/>
<select
value={row.type}
onChange={(e) => updateRow(idx, 'type', e.target.value)}
style={{ ...inputStyle, width: 80 }}
disabled={!isEditor}
>
<option value="string">str</option>
<option value="number">num</option>
<option value="boolean">bool</option>
</select>
{row.type === 'boolean' ? (
<select value={row.value} onChange={(e) => updateRow(idx, 'value', e.target.value)} style={{ ...inputStyle, flex: 1 }} disabled={!isEditor}>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
type={row.type === 'number' ? 'number' : 'text'}
value={row.value}
onChange={(e) => updateRow(idx, 'value', e.target.value)}
placeholder="Value"
style={{ ...inputStyle, flex: 1 }}
disabled={!isEditor}
/>
)}
{isEditor && (
<button onClick={() => removeRow(idx)} style={{ background: 'none', border: 'none', color: 'var(--ctp-red)', cursor: 'pointer', fontSize: '0.9rem' }}>×</button>
)}
</div>
))}
{isEditor && (
<button onClick={addRow} style={{ ...tabBtn, marginTop: '0.25rem' }}>+ Add Property</button>
)}
</div>
) : (
<div>
<textarea
value={jsonText}
onChange={(e) => { setJsonText(e.target.value); setJsonError(null); }}
disabled={!isEditor}
style={{
width: '100%', minHeight: 200, padding: '0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8rem',
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.4rem', color: 'var(--ctp-text)', resize: 'vertical',
}}
/>
{jsonError && <div style={{ color: 'var(--ctp-red)', fontSize: '0.8rem', marginTop: '0.25rem' }}>{jsonError}</div>}
</div>
)}
</div>
);
}
const tabBtn: React.CSSProperties = {
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-surface0)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
};
const activeTabBtn: React.CSSProperties = {
...tabBtn,
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-mauve)',
};

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react';
import { get, post } from '../../api/client';
import type { Revision, RevisionComparison } from '../../api/types';
interface RevisionsTabProps {
partNumber: string;
isEditor: boolean;
}
const statusColors: Record<string, string> = {
draft: 'var(--ctp-overlay1)',
review: 'var(--ctp-yellow)',
released: 'var(--ctp-green)',
obsolete: 'var(--ctp-red)',
};
function formatDate(s: string) {
if (!s) return '';
return new Date(s).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
const [revisions, setRevisions] = useState<Revision[]>([]);
const [loading, setLoading] = useState(true);
const [compareFrom, setCompareFrom] = useState('');
const [compareTo, setCompareTo] = useState('');
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
const load = () => {
setLoading(true);
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
.then((r) => { setRevisions(r); setLoading(false); })
.catch(() => setLoading(false));
};
useEffect(load, [partNumber]);
const handleCompare = async () => {
if (!compareFrom || !compareTo) return;
try {
const result = await get<RevisionComparison>(
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`
);
setComparison(result);
} catch (e) {
alert(e instanceof Error ? e.message : 'Compare failed');
}
};
const handleStatusChange = async (rev: number, status: string) => {
try {
await fetch(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ status }),
});
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Status update failed');
}
};
const handleRollback = async (rev: number) => {
if (!confirm(`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`)) return;
const comment = prompt('Rollback comment:') ?? `Rollback to rev ${rev}`;
try {
await post(`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`, { comment });
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Rollback failed');
}
};
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading revisions...</div>;
const selectStyle: React.CSSProperties = {
padding: '0.25rem 0.4rem', fontSize: '0.8rem',
backgroundColor: 'var(--ctp-surface0)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)',
};
return (
<div>
{/* Compare controls */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.75rem' }}>
<select value={compareFrom} onChange={(e) => setCompareFrom(e.target.value)} style={selectStyle}>
<option value="">From rev...</option>
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)}
</select>
<select value={compareTo} onChange={(e) => setCompareTo(e.target.value)} style={selectStyle}>
<option value="">To rev...</option>
{revisions.map((r) => <option key={r.id} value={r.revision_number}>Rev {r.revision_number}</option>)}
</select>
<button onClick={() => void handleCompare()} disabled={!compareFrom || !compareTo} style={{
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-mauve)', color: 'var(--ctp-crust)', cursor: 'pointer',
opacity: (!compareFrom || !compareTo) ? 0.5 : 1,
}}>
Compare
</button>
</div>
{/* Compare results */}
{comparison && (
<div style={{
padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem',
fontSize: '0.8rem', marginBottom: '0.75rem', fontFamily: "'JetBrains Mono', monospace",
}}>
{comparison.status_changed && (
<div>Status: <span style={{ color: 'var(--ctp-red)' }}>{comparison.status_changed.from}</span> <span style={{ color: 'var(--ctp-green)' }}>{comparison.status_changed.to}</span></div>
)}
{comparison.file_changed && <div style={{ color: 'var(--ctp-yellow)' }}>File changed</div>}
{Object.entries(comparison.added).map(([k, v]) => (
<div key={k} style={{ color: 'var(--ctp-green)' }}>+ {k}: {String(v)}</div>
))}
{Object.entries(comparison.removed).map(([k, v]) => (
<div key={k} style={{ color: 'var(--ctp-red)' }}>- {k}: {String(v)}</div>
))}
{Object.entries(comparison.changed).map(([k, c]) => (
<div key={k} style={{ color: 'var(--ctp-yellow)' }}>~ {k}: {String(c.from)} {String(c.to)}</div>
))}
{!comparison.status_changed && !comparison.file_changed &&
Object.keys(comparison.added).length === 0 && Object.keys(comparison.removed).length === 0 &&
Object.keys(comparison.changed).length === 0 && (
<div style={{ color: 'var(--ctp-subtext0)' }}>No differences</div>
)}
</div>
)}
{/* Revisions table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={thStyle}>Rev</th>
<th style={thStyle}>Status</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>By</th>
<th style={thStyle}>Comment</th>
<th style={thStyle}>File</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{revisions.map((rev, idx) => (
<tr key={rev.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
<td style={tdStyle}>{rev.revision_number}</td>
<td style={tdStyle}>
{isEditor ? (
<select
value={rev.status}
onChange={(e) => void handleStatusChange(rev.revision_number, e.target.value)}
style={{
padding: '0.1rem 0.3rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'transparent', color: statusColors[rev.status] ?? 'var(--ctp-text)',
cursor: 'pointer',
}}
>
<option value="draft">draft</option>
<option value="review">review</option>
<option value="released">released</option>
<option value="obsolete">obsolete</option>
</select>
) : (
<span style={{ color: statusColors[rev.status] ?? 'var(--ctp-text)' }}>{rev.status}</span>
)}
</td>
<td style={tdStyle}>{formatDate(rev.created_at)}</td>
<td style={tdStyle}>{rev.created_by ?? '—'}</td>
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{rev.comment ?? ''}</td>
<td style={tdStyle}>
{rev.file_key ? (
<button
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`; }}
style={{ background: 'none', border: 'none', color: 'var(--ctp-sapphire)', cursor: 'pointer', fontSize: '0.8rem' }}
>
</button>
) : '—'}
</td>
{isEditor && (
<td style={tdStyle}>
<button
onClick={() => void handleRollback(rev.revision_number)}
style={{ background: 'none', border: 'none', color: 'var(--ctp-peach)', cursor: 'pointer', fontSize: '0.75rem' }}
title="Rollback to this revision"
>
Rollback
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
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',
};
const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
};

View File

@@ -0,0 +1,111 @@
import { useState, useCallback, useRef, useEffect, type ReactNode } from 'react';
interface SplitPanelProps {
layout: 'horizontal' | 'vertical';
primary: ReactNode;
secondary: ReactNode | null;
storageKey?: string;
}
export function SplitPanel({ layout, primary, secondary, storageKey = 'silo-split-size' }: SplitPanelProps) {
const containerRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const [primarySize, setPrimarySize] = useState(() => {
try {
const saved = localStorage.getItem(`${storageKey}-${layout}`);
return saved ? Number(saved) : (layout === 'horizontal' ? 50 : 45);
} catch {
return layout === 'horizontal' ? 50 : 45;
}
});
// Update default size when layout changes
useEffect(() => {
try {
const saved = localStorage.getItem(`${storageKey}-${layout}`);
if (saved) setPrimarySize(Number(saved));
else setPrimarySize(layout === 'horizontal' ? 50 : 45);
} catch {
setPrimarySize(layout === 'horizontal' ? 50 : 45);
}
}, [layout, storageKey]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
document.body.style.cursor = layout === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
}, [layout]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let pct: number;
if (layout === 'horizontal') {
pct = ((e.clientX - rect.left) / rect.width) * 100;
} else {
pct = ((e.clientY - rect.top) / rect.height) * 100;
}
pct = Math.max(20, Math.min(80, pct));
setPrimarySize(pct);
};
const handleMouseUp = () => {
if (dragging.current) {
dragging.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
try {
localStorage.setItem(`${storageKey}-${layout}`, String(primarySize));
} catch { /* ignore */ }
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [layout, primarySize, storageKey]);
if (!secondary) {
return <div style={{ flex: 1, overflow: 'auto' }}>{primary}</div>;
}
const isHoriz = layout === 'horizontal';
return (
<div
ref={containerRef}
style={{
display: 'flex',
flexDirection: isHoriz ? 'row' : 'column',
flex: 1,
overflow: 'hidden',
}}
>
<div style={{
[isHoriz ? 'width' : 'height']: `${primarySize}%`,
overflow: 'auto',
flexShrink: 0,
}}>
{primary}
</div>
<div
onMouseDown={handleMouseDown}
style={{
[isHoriz ? 'width' : 'height']: 4,
backgroundColor: 'var(--ctp-surface1)',
cursor: isHoriz ? 'col-resize' : 'row-resize',
flexShrink: 0,
}}
/>
<div style={{ flex: 1, overflow: 'auto' }}>
{secondary}
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useState, useEffect } from 'react';
import { get } from '../../api/client';
import type { WhereUsedEntry } from '../../api/types';
interface WhereUsedTabProps {
partNumber: string;
}
export function WhereUsedTab({ partNumber }: WhereUsedTabProps) {
const [entries, setEntries] = useState<WhereUsedEntry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
get<WhereUsedEntry[]>(`/api/items/${encodeURIComponent(partNumber)}/bom/where-used`)
.then(setEntries)
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [partNumber]);
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading where-used...</div>;
if (entries.length === 0) {
return <div style={{ color: 'var(--ctp-subtext0)', padding: '1rem' }}>Not used in any assemblies.</div>;
}
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr>
<th style={thStyle}>Parent PN</th>
<th style={thStyle}>Description</th>
<th style={thStyle}>Relationship</th>
<th style={thStyle}>QTY</th>
</tr>
</thead>
<tbody>
{entries.map((e, idx) => (
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{e.parent_part_number}
</td>
<td style={tdStyle}>{e.parent_description}</td>
<td style={tdStyle}>{e.rel_type}</td>
<td style={tdStyle}>{e.quantity ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
}
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',
};
const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
};

89
web/src/hooks/useItems.ts Normal file
View File

@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { get } from '../api/client';
import type { Item, FuzzyResult } from '../api/types';
export interface ItemFilters {
search: string;
searchScope: 'all' | 'part_number' | 'description';
type: string;
project: string;
page: number;
pageSize: number;
}
const defaultFilters: ItemFilters = {
search: '',
searchScope: 'all',
type: '',
project: '',
page: 1,
pageSize: 50,
};
export function useItems() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<ItemFilters>(defaultFilters);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const fetchItems = useCallback(async (f: ItemFilters) => {
setLoading(true);
setError(null);
try {
let result: Item[];
if (f.search) {
const params = new URLSearchParams({ q: f.search });
if (f.searchScope !== 'all') params.set('fields', f.searchScope);
if (f.type) params.set('type', f.type);
if (f.project) params.set('project', f.project);
params.set('limit', String(f.pageSize));
result = await get<FuzzyResult[]>(`/api/items/search?${params}`);
} else {
const params = new URLSearchParams();
if (f.type) params.set('type', f.type);
if (f.project) params.set('project', f.project);
params.set('limit', String(f.pageSize));
params.set('offset', String((f.page - 1) * f.pageSize));
const qs = params.toString();
result = await get<Item[]>(`/api/items${qs ? `?${qs}` : ''}`);
}
setItems(result);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load items');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (filters.search) {
debounceRef.current = setTimeout(() => {
void fetchItems(filters);
}, 300);
} else {
void fetchItems(filters);
}
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [filters, fetchItems]);
const updateFilters = useCallback((partial: Partial<ItemFilters>) => {
setFilters((prev) => {
const next = { ...prev, ...partial };
// Reset to page 1 when filters change (but not when page itself changes)
if (!('page' in partial)) next.page = 1;
return next;
});
}, []);
const reload = useCallback(() => {
void fetchItems(filters);
}, [filters, fetchItems]);
return { items, loading, error, filters, updateFilters, reload };
}

View File

@@ -0,0 +1,26 @@
import { useState, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const next = value instanceof Function ? value(prev) : value;
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch {
// localStorage full or unavailable
}
return next;
});
}, [key]);
return [storedValue, setValue];
}

View File

@@ -1,70 +1,263 @@
import { useEffect, useState } from 'react';
import { get } from '../api/client';
import type { Item } from '../api/types';
import { useState, useCallback, useEffect } from "react";
import { useAuth } from "../hooks/useAuth";
import { useItems } from "../hooks/useItems";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { ItemsToolbar } from "../components/items/ItemsToolbar";
import {
ItemTable,
DEFAULT_COLUMNS_H,
DEFAULT_COLUMNS_V,
} from "../components/items/ItemTable";
import { ItemDetail } from "../components/items/ItemDetail";
import { CreateItemPane } from "../components/items/CreateItemPane";
import { EditItemPane } from "../components/items/EditItemPane";
import { DeleteItemPane } from "../components/items/DeleteItemPane";
import { ImportItemsPane } from "../components/items/ImportItemsPane";
import { SplitPanel } from "../components/items/SplitPanel";
import { FooterStats } from "../components/items/FooterStats";
type PaneMode =
| { type: "none" }
| { type: "detail"; partNumber: string }
| { type: "create" }
| { type: "edit"; partNumber: string }
| { type: "delete"; partNumber: string }
| { type: "import" };
export function ItemsPage() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
const isEditor = user?.role === "admin" || user?.role === "editor";
const { items, loading, error, filters, updateFilters, reload } = useItems();
useEffect(() => {
get<Item[]>('/api/items')
.then(setItems)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
const [layout, setLayout] = useLocalStorage<"horizontal" | "vertical">(
"silo-items-layout",
"horizontal",
);
const [columnsH, setColumnsH] = useLocalStorage<string[]>(
"silo-items-columns-h",
DEFAULT_COLUMNS_H,
);
const [columnsV, setColumnsV] = useLocalStorage<string[]>(
"silo-items-columns-v",
DEFAULT_COLUMNS_V,
);
const [sortKey, setSortKey] = useState("part_number");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [pane, setPane] = useState<PaneMode>({ type: "none" });
const visibleColumns = layout === "horizontal" ? columnsH : columnsV;
const setVisibleColumns = layout === "horizontal" ? setColumnsH : setColumnsV;
const handleSort = useCallback(
(key: string) => {
setSortDir((prev) =>
sortKey === key ? (prev === "asc" ? "desc" : "asc") : "asc",
);
setSortKey(key);
},
[sortKey],
);
const handleSelect = useCallback((pn: string) => {
setPane({ type: "detail", partNumber: pn });
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading items...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
const handleClose = useCallback(() => {
setPane({ type: "none" });
}, []);
const handleEdit = useCallback((pn: string) => {
setPane({ type: "edit", partNumber: pn });
}, []);
const handleDelete = useCallback((pn: string) => {
setPane({ type: "delete", partNumber: pn });
}, []);
const handleExport = useCallback(() => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.type) params.set("type", filters.type);
if (filters.project) params.set("project", filters.project);
params.set("include_properties", "true");
window.location.href = `/api/items/export.csv?${params}`;
}, [filters]);
// Ctrl+F handler
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
e.preventDefault();
const input = document.querySelector<HTMLInputElement>(
'input[placeholder*="Search"]',
);
input?.focus();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
// Build the secondary pane content based on current mode
let secondaryPane: React.ReactNode = null;
switch (pane.type) {
case "detail":
secondaryPane = (
<ItemDetail
partNumber={pane.partNumber}
onClose={handleClose}
onEdit={handleEdit}
onDelete={handleDelete}
onReload={reload}
isEditor={isEditor}
/>
);
break;
case "create":
secondaryPane = (
<CreateItemPane
onCreated={(pn) => {
reload();
setPane({ type: "detail", partNumber: pn });
}}
onCancel={handleClose}
/>
);
break;
case "edit":
secondaryPane = (
<EditItemPane
partNumber={pane.partNumber}
onSaved={() => {
reload();
setPane({ type: "detail", partNumber: pane.partNumber });
}}
onCancel={() =>
setPane({ type: "detail", partNumber: pane.partNumber })
}
/>
);
break;
case "delete":
secondaryPane = (
<DeleteItemPane
partNumber={pane.partNumber}
onDeleted={() => {
reload();
setPane({ type: "none" });
}}
onCancel={() =>
setPane({ type: "detail", partNumber: pane.partNumber })
}
/>
);
break;
case "import":
secondaryPane = (
<ImportItemsPane
onImported={() => {
reload();
setPane({ type: "none" });
}}
onCancel={handleClose}
/>
);
break;
}
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Items ({items.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Part Number</th>
<th style={thStyle}>Type</th>
<th style={thStyle}>Description</th>
<th style={thStyle}>Rev</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{item.part_number}
</td>
<td style={tdStyle}>{item.item_type}</td>
<td style={tdStyle}>{item.description}</td>
<td style={tdStyle}>{item.current_revision}</td>
</tr>
))}
</tbody>
</table>
<div
style={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 80px)",
paddingBottom: 28,
}}
>
{error && (
<div
style={{
color: "var(--ctp-red)",
padding: "0.5rem",
fontSize: "0.85rem",
}}
>
Error: {error}
</div>
)}
<ItemsToolbar
filters={filters}
onFilterChange={updateFilters}
layout={layout}
onLayoutChange={setLayout}
onExport={handleExport}
onImport={() => setPane({ type: "import" })}
onCreate={() => setPane({ type: "create" })}
isEditor={isEditor}
/>
<SplitPanel
layout={layout}
primary={
<ItemTable
items={items}
loading={loading}
selectedPN={pane.type === "detail" ? pane.partNumber : null}
onSelect={handleSelect}
visibleColumns={visibleColumns}
onColumnsChange={setVisibleColumns}
onEdit={isEditor ? handleEdit : undefined}
onDelete={isEditor ? handleDelete : undefined}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
/>
}
secondary={secondaryPane}
/>
{/* Pagination */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "0.75rem",
padding: "0.4rem",
flexShrink: 0,
}}
>
<button
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
disabled={filters.page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
Page {filters.page} · {items.length} items
</span>
<button
onClick={() => updateFilters({ page: filters.page + 1 })}
disabled={items.length < filters.pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
<FooterStats items={items} />
</div>
);
}
const thStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
textAlign: 'left',
borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
fontWeight: 600,
fontSize: '0.85rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
const tdStyle: React.CSSProperties = {
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--ctp-surface1)',
const pageBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.6rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
cursor: "pointer",
};