542 lines
24 KiB
TypeScript
542 lines
24 KiB
TypeScript
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<string | null>(null)
|
|
const [editName, setEditName] = useState('')
|
|
const [editDesc, setEditDesc] = useState('')
|
|
const [collapsed, setCollapsed] = useState<Set<string | null>>(new Set())
|
|
const [expandedAliases, setExpandedAliases] = useState<Set<string>>(new Set())
|
|
const [aliasInput, setAliasInput] = useState<Record<string, string>>({})
|
|
|
|
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<string | null, Material[]>()
|
|
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 (
|
|
<div className="p-8 max-w-5xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<FlaskConical size={22} className="text-accent" />
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-content">Material Library</h1>
|
|
<p className="text-sm text-content-secondary mt-0.5">
|
|
Shared materials used when assigning CAD part materials to order items.
|
|
{totalAliases > 0 && <span className="ml-2 text-content-muted">({totalAliases} aliases configured)</span>}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm('Import 35 Schaeffler standard materials? Existing entries will be skipped.'))
|
|
seedMut.mutate()
|
|
}}
|
|
disabled={seedMut.isPending}
|
|
className="btn-secondary text-sm flex items-center gap-1.5"
|
|
title="Import the 35 standard Schaeffler SCHAEFFLER_... materials used in Blender material libraries. Existing entries are skipped."
|
|
>
|
|
<Download size={14} /> {seedMut.isPending ? 'Importing...' : 'Import Standards'}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm('Seed material aliases from naming scheme mappings? Existing aliases will be skipped.'))
|
|
seedAliasMut.mutate()
|
|
}}
|
|
disabled={seedAliasMut.isPending}
|
|
className="btn-secondary text-sm flex items-center gap-1.5"
|
|
title="Seed ~100 material aliases from the Schaeffler naming scheme (German descriptions, intermediate codes → SCHAEFFLER_... library names). Existing aliases are skipped."
|
|
>
|
|
<Tag size={14} /> {seedAliasMut.isPending ? 'Seeding...' : 'Seed Aliases'}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowWizard(true)}
|
|
className="btn-secondary text-sm flex items-center gap-1.5"
|
|
title="Open the Schaeffler Wizard — guided tool to set up SCHAEFFLER_... materials and aliases from the standard naming scheme"
|
|
>
|
|
<Wand2 size={14} /> Schaeffler Wizard
|
|
</button>
|
|
<button onClick={() => setShowAdd(!showAdd)} className="btn-primary">
|
|
<Plus size={16} /> Add Material
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add form */}
|
|
{showAdd && (
|
|
<div className="card p-4 mb-6 bg-surface-alt flex gap-3 items-end flex-wrap">
|
|
<div className="flex-1 min-w-[160px]">
|
|
<label className="block text-xs font-medium text-content-secondary mb-1">Name *</label>
|
|
<input
|
|
autoFocus
|
|
placeholder="e.g. Steel 100Cr6"
|
|
value={newName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="block text-xs font-medium text-content-secondary mb-1">Description</label>
|
|
<input
|
|
placeholder="e.g. Bearing steel, hardened"
|
|
value={newDesc}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => createMut.mutate()}
|
|
disabled={!newName.trim() || createMut.isPending}
|
|
className="btn-primary text-sm"
|
|
>
|
|
{createMut.isPending ? 'Adding...' : 'Add'}
|
|
</button>
|
|
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search */}
|
|
<div className="relative mb-4">
|
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search materials or aliases..."
|
|
value={search}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
|
|
{/* Grouped table */}
|
|
{isLoading ? (
|
|
<div className="card p-8 text-center text-content-muted text-sm">Loading...</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="card p-8 text-center text-content-muted text-sm">
|
|
{search ? 'No materials match your search.' : 'No materials yet. Add the first one above.'}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{groups.map((group) => {
|
|
const Icon = group.icon
|
|
const isCollapsed = collapsed.has(group.code)
|
|
return (
|
|
<div key={group.code ?? 'custom'} className={`card overflow-hidden border ${group.border}`}>
|
|
{/* Group header */}
|
|
<button
|
|
onClick={() => toggleGroup(group.code)}
|
|
className={`w-full flex items-center gap-3 px-5 py-3 ${group.bg} hover:brightness-95 transition-all`}
|
|
>
|
|
{isCollapsed ? <ChevronRight size={16} className={group.text} /> : <ChevronDown size={16} className={group.text} />}
|
|
<Icon size={16} className={group.text} />
|
|
<span className={`text-sm font-semibold ${group.text}`}>
|
|
{group.code ? `${group.code} — ` : ''}{group.label}
|
|
</span>
|
|
<span className="text-xs text-content-muted ml-auto">{group.items.length} material{group.items.length !== 1 ? 's' : ''}</span>
|
|
</button>
|
|
|
|
{!isCollapsed && (
|
|
<>
|
|
{/* Column header */}
|
|
<div className="grid grid-cols-[2fr_2fr_1fr_1fr_1fr] bg-surface border-b border-border-light px-6 py-1.5">
|
|
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Name</p>
|
|
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Description</p>
|
|
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Source</p>
|
|
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Aliases</p>
|
|
<p className="text-[10px] font-semibold text-content-muted uppercase tracking-wide">Actions</p>
|
|
</div>
|
|
|
|
{/* Rows */}
|
|
<div className="divide-y divide-border-light">
|
|
{group.items.map((mat) => {
|
|
const aliasesExpanded = expandedAliases.has(mat.id)
|
|
return (
|
|
<div key={mat.id}>
|
|
<div className="grid grid-cols-[2fr_2fr_1fr_1fr_1fr] items-center px-6 py-2.5 gap-3 hover:bg-surface-hover">
|
|
{editingId === mat.id ? (
|
|
<div className="col-span-5 flex items-center gap-3 flex-wrap">
|
|
<input
|
|
autoFocus
|
|
value={editName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<input
|
|
value={editDesc}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={() => updateMut.mutate(mat.id)}
|
|
disabled={!editName.trim() || updateMut.isPending}
|
|
className="text-status-success-text hover:text-status-success-text"
|
|
title="Save"
|
|
>
|
|
<Check size={16} />
|
|
</button>
|
|
<button onClick={() => setEditingId(null)} className="text-content-muted hover:text-content" title="Cancel">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-content truncate">{mat.name}</p>
|
|
{mat.schaeffler_code != null && (
|
|
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-content-muted truncate">{mat.description || '—'}</p>
|
|
</div>
|
|
<div>
|
|
<SourceBadge source={mat.source} />
|
|
</div>
|
|
<div>
|
|
<button
|
|
onClick={() => toggleAliases(mat.id)}
|
|
className="inline-flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary"
|
|
title={`${mat.aliases.length} alias${mat.aliases.length !== 1 ? 'es' : ''} — click to ${aliasesExpanded ? 'collapse' : 'expand'}`}
|
|
>
|
|
<Tag size={12} />
|
|
<span>{mat.aliases.length}</span>
|
|
{mat.aliases.length > 0 && (
|
|
aliasesExpanded
|
|
? <ChevronDown size={12} />
|
|
: <ChevronRight size={12} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => startEdit(mat)} className="text-content-muted hover:text-content" title="Edit">
|
|
<Pencil size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (confirm(`Delete material "${mat.name}"?`)) deleteMut.mutate(mat.id)
|
|
}}
|
|
className="text-content-muted hover:text-red-500"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Expandable alias section */}
|
|
{aliasesExpanded && editingId !== mat.id && (
|
|
<div className="px-8 pb-3 pt-1 bg-surface-alt/50 border-t border-border-light">
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{mat.aliases.length === 0 && (
|
|
<span className="text-xs text-content-muted italic">No aliases configured</span>
|
|
)}
|
|
{mat.aliases.map((alias) => (
|
|
<AliasPill
|
|
key={alias}
|
|
alias={alias}
|
|
materialId={mat.id}
|
|
onDelete={deleteAliasMut}
|
|
materials={materials}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder="Add alias (e.g. Stahl, brüniert)"
|
|
value={aliasInput[mat.id] || ''}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={() => handleAddAlias(mat.id)}
|
|
disabled={!(aliasInput[mat.id] || '').trim() || addAliasMut.isPending}
|
|
className="text-xs text-accent hover:text-accent-hover font-medium disabled:opacity-40"
|
|
>
|
|
+ Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Footer count */}
|
|
<div className="text-center py-2">
|
|
<p className="text-xs text-content-muted">
|
|
{filtered.length} of {materials.length} material{materials.length !== 1 ? 's' : ''}
|
|
{totalAliases > 0 && ` · ${totalAliases} aliases`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Wizard modal */}
|
|
<MaterialWizard open={showWizard} onClose={() => setShowWizard(false)} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<span className="inline-flex items-center gap-1 text-xs bg-indigo-50 text-indigo-700 px-2 py-0.5 rounded-full">
|
|
{alias}
|
|
<button
|
|
onClick={handleDelete}
|
|
className="text-indigo-400 hover:text-red-500 ml-0.5"
|
|
title={`Remove alias "${alias}"`}
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SourceBadge({ source }: { source: string }) {
|
|
if (source === 'schaeffler_standard') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium bg-status-success-bg text-status-success-text px-2 py-0.5 rounded-full">
|
|
Standard
|
|
</span>
|
|
)
|
|
}
|
|
if (source === 'cad_import') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
|
|
CAD import
|
|
</span>
|
|
)
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1 text-xs font-medium bg-surface-muted text-content-secondary px-2 py-0.5 rounded-full">
|
|
Manual
|
|
</span>
|
|
)
|
|
}
|