import { useState, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Plus, Trash2, Pencil, Check, X, FlaskConical, Search, Wand2, Download, Wrench, Paintbrush, Shapes, HelpCircle, ChevronDown, ChevronRight, Tag, } from 'lucide-react' import { listMaterials, createMaterial, updateMaterial, deleteMaterial, seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases, } from '../api/materials' import type { Material } from '../api/materials' import MaterialWizard from '../components/MaterialWizard' const TYPE_GROUPS = [ { code: '01', label: 'Metals', icon: Wrench, bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-700' }, { code: '02', label: 'Coatings', icon: Paintbrush, bg: 'bg-status-info-bg', border: 'border-border-default', text: 'text-status-info-text' }, { code: '03', label: 'Non-metals', icon: Shapes, bg: 'bg-status-warning-bg', border: 'border-border-default', text: 'text-status-warning-text' }, { code: '04', label: 'Compounds', icon: FlaskConical, bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700' }, { code: '05', label: 'Misc', icon: HelpCircle, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary' }, ] as const function getTypeCode(mat: Material): string | null { if (mat.schaeffler_code == null) return null return String(mat.schaeffler_code).padStart(6, '0').slice(0, 2) } interface MaterialGroup { code: string | null label: string icon: typeof Wrench bg: string border: string text: string items: Material[] } export default function MaterialsPage() { const qc = useQueryClient() const [search, setSearch] = useState('') const [showAdd, setShowAdd] = useState(false) const [showWizard, setShowWizard] = useState(false) const [newName, setNewName] = useState('') const [newDesc, setNewDesc] = useState('') const [editingId, setEditingId] = useState(null) const [editName, setEditName] = useState('') const [editDesc, setEditDesc] = useState('') const [collapsed, setCollapsed] = useState>(new Set()) const [expandedAliases, setExpandedAliases] = useState>(new Set()) const [aliasInput, setAliasInput] = useState>({}) const { data: materials = [], isLoading } = useQuery({ queryKey: ['materials'], queryFn: listMaterials, }) const createMut = useMutation({ mutationFn: () => createMaterial({ name: newName.trim(), description: newDesc.trim() || undefined }), onSuccess: () => { toast.success('Material added') qc.invalidateQueries({ queryKey: ['materials'] }) setShowAdd(false) setNewName('') setNewDesc('') }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add material'), }) const updateMut = useMutation({ mutationFn: (id: string) => updateMaterial(id, { name: editName.trim(), description: editDesc.trim() || undefined }), onSuccess: () => { toast.success('Material updated') qc.invalidateQueries({ queryKey: ['materials'] }) setEditingId(null) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'), }) const deleteMut = useMutation({ mutationFn: deleteMaterial, onSuccess: () => { toast.success('Material deleted') qc.invalidateQueries({ queryKey: ['materials'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'), }) const seedMut = useMutation({ mutationFn: seedSchaefflerMaterials, onSuccess: (data) => { if (data.inserted > 0) { toast.success(`Imported ${data.inserted} of ${data.total} Schaeffler standard materials`) } else { toast.info('All Schaeffler standard materials already exist') } qc.invalidateQueries({ queryKey: ['materials'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to import'), }) const seedAliasMut = useMutation({ mutationFn: seedAliases, onSuccess: (data) => { if (data.inserted > 0) { toast.success(`Seeded ${data.inserted} aliases (${data.total} total checked)`) } else { toast.info('All aliases already exist') } qc.invalidateQueries({ queryKey: ['materials'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to seed aliases'), }) const addAliasMut = useMutation({ mutationFn: ({ materialId, alias }: { materialId: string; alias: string }) => addAlias(materialId, alias), onSuccess: (_data, vars) => { toast.success('Alias added') qc.invalidateQueries({ queryKey: ['materials'] }) setAliasInput((prev) => ({ ...prev, [vars.materialId]: '' })) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to add alias'), }) const deleteAliasMut = useMutation({ mutationFn: deleteAlias, onSuccess: () => { toast.success('Alias removed') qc.invalidateQueries({ queryKey: ['materials'] }) }, onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to remove alias'), }) const startEdit = (mat: Material) => { setEditingId(mat.id) setEditName(mat.name) setEditDesc(mat.description ?? '') } const toggleAliases = (id: string) => { setExpandedAliases((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const handleAddAlias = (materialId: string) => { const val = (aliasInput[materialId] || '').trim() if (val) addAliasMut.mutate({ materialId, alias: val }) } // Search filters include aliases const filtered = search.trim() ? materials.filter((m) => { const q = search.toLowerCase() return ( m.name.toLowerCase().includes(q) || m.description?.toLowerCase().includes(q) || m.aliases.some((a) => a.toLowerCase().includes(q)) ) }) : materials // Group filtered materials by type code const groups = useMemo((): MaterialGroup[] => { const buckets = new Map() for (const m of filtered) { const tc = getTypeCode(m) if (!buckets.has(tc)) buckets.set(tc, []) buckets.get(tc)!.push(m) } const result: MaterialGroup[] = [] // Known type groups first for (const tg of TYPE_GROUPS) { const items = buckets.get(tg.code) if (items && items.length > 0) { result.push({ code: tg.code, label: tg.label, icon: tg.icon, bg: tg.bg, border: tg.border, text: tg.text, items }) buckets.delete(tg.code) } } // Custom / non-schaeffler materials const custom = buckets.get(null) if (custom && custom.length > 0) { result.push({ code: null, label: 'Custom', icon: Plus, bg: 'bg-surface-alt', border: 'border-border-default', text: 'text-content-secondary', items: custom }) } return result }, [filtered]) const toggleGroup = (code: string | null) => { setCollapsed((prev) => { const next = new Set(prev) if (next.has(code)) next.delete(code) else next.add(code) return next }) } const totalAliases = materials.reduce((sum, m) => sum + m.aliases.length, 0) return (
{/* Header */}

Material Library

Shared materials used when assigning CAD part materials to order items. {totalAliases > 0 && ({totalAliases} aliases configured)}

{/* Add form */} {showAdd && (
setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()} className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent" />
setNewDesc(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && newName.trim() && createMut.mutate()} className="w-full px-3 py-1.5 border border-border-default rounded-md text-sm focus:outline-none focus:border-accent" />
)} {/* Search */}
setSearch(e.target.value)} className="w-full pl-9 pr-4 py-2 border border-border-default rounded-lg text-sm focus:outline-none focus:border-accent bg-surface" />
{/* Grouped table */} {isLoading ? (
Loading...
) : filtered.length === 0 ? (
{search ? 'No materials match your search.' : 'No materials yet. Add the first one above.'}
) : (
{groups.map((group) => { const Icon = group.icon const isCollapsed = collapsed.has(group.code) return (
{/* Group header */} {!isCollapsed && ( <> {/* Column header */}

Name

Description

Source

Aliases

Actions

{/* Rows */}
{group.items.map((mat) => { const aliasesExpanded = expandedAliases.has(mat.id) return (
{editingId === mat.id ? (
setEditName(e.target.value)} placeholder="Name" className="flex-1 min-w-[140px] px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent" /> setEditDesc(e.target.value)} placeholder="Description" className="flex-1 min-w-[200px] px-2 py-1 border border-border-default rounded text-sm focus:outline-none focus:border-accent" />
) : ( <>

{mat.name}

{mat.schaeffler_code != null && (

Nr: {mat.schaeffler_code}

)}

{mat.description || '—'}

)}
{/* Expandable alias section */} {aliasesExpanded && editingId !== mat.id && (
{mat.aliases.length === 0 && ( No aliases configured )} {mat.aliases.map((alias) => ( ))}
setAliasInput((prev) => ({ ...prev, [mat.id]: e.target.value }))} onKeyDown={(e) => { if (e.key === 'Enter') handleAddAlias(mat.id) }} className="flex-1 max-w-xs px-2 py-1 border border-border-default rounded text-xs focus:outline-none focus:border-accent" />
)}
) })}
)}
) })} {/* Footer count */}

{filtered.length} of {materials.length} material{materials.length !== 1 ? 's' : ''} {totalAliases > 0 && ` · ${totalAliases} aliases`}

)} {/* Wizard modal */} setShowWizard(false)} />
) } function AliasPill({ alias, materialId, onDelete, materials, }: { alias: string materialId: string onDelete: { mutate: (id: string) => void; isPending: boolean } materials: Material[] }) { // We need the alias ID for deletion - find it from the material's aliases list // Since we only have alias strings from MaterialOut, we need to query the ID // We'll use a lazy approach: delete by fetching aliases for this material const handleDelete = async () => { try { const { listAliases: fetchAliases } = await import('../api/materials') const aliases = await fetchAliases(materialId) const found = aliases.find((a) => a.alias === alias) if (found) { onDelete.mutate(found.id) } } catch { // Fallback: ignore } } return ( {alias} ) } function SourceBadge({ source }: { source: string }) { if (source === 'schaeffler_standard') { return ( Standard ) } if (source === 'cad_import') { return ( CAD import ) } return ( Manual ) }