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:
@@ -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;
|
||||
}
|
||||
|
||||
105
web/src/components/ContextMenu.tsx
Normal file
105
web/src/components/ContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
web/src/components/items/BOMTab.tsx
Normal file
238
web/src/components/items/BOMTab.tsx
Normal 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',
|
||||
};
|
||||
215
web/src/components/items/CreateItemPane.tsx
Normal file
215
web/src/components/items/CreateItemPane.tsx
Normal 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',
|
||||
};
|
||||
84
web/src/components/items/DeleteItemPane.tsx
Normal file
84
web/src/components/items/DeleteItemPane.tsx
Normal 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',
|
||||
};
|
||||
150
web/src/components/items/EditItemPane.tsx
Normal file
150
web/src/components/items/EditItemPane.tsx
Normal 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',
|
||||
};
|
||||
36
web/src/components/items/FooterStats.tsx
Normal file
36
web/src/components/items/FooterStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
web/src/components/items/ImportItemsPane.tsx
Normal file
179
web/src/components/items/ImportItemsPane.tsx
Normal 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',
|
||||
};
|
||||
202
web/src/components/items/ItemDetail.tsx
Normal file
202
web/src/components/items/ItemDetail.tsx
Normal 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",
|
||||
};
|
||||
262
web/src/components/items/ItemTable.tsx
Normal file
262
web/src/components/items/ItemTable.tsx
Normal 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',
|
||||
};
|
||||
152
web/src/components/items/ItemsToolbar.tsx
Normal file
152
web/src/components/items/ItemsToolbar.tsx
Normal 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',
|
||||
};
|
||||
169
web/src/components/items/MainTab.tsx
Normal file
169
web/src/components/items/MainTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
web/src/components/items/PropertiesTab.tsx
Normal file
209
web/src/components/items/PropertiesTab.tsx
Normal 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)',
|
||||
};
|
||||
207
web/src/components/items/RevisionsTab.tsx
Normal file
207
web/src/components/items/RevisionsTab.tsx
Normal 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',
|
||||
};
|
||||
111
web/src/components/items/SplitPanel.tsx
Normal file
111
web/src/components/items/SplitPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
web/src/components/items/WhereUsedTab.tsx
Normal file
60
web/src/components/items/WhereUsedTab.tsx
Normal 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
89
web/src/hooks/useItems.ts
Normal 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 };
|
||||
}
|
||||
26
web/src/hooks/useLocalStorage.ts
Normal file
26
web/src/hooks/useLocalStorage.ts
Normal 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];
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user