Files
HartOMat/frontend/src/pages/Materials.tsx
T
2026-03-05 22:12:38 +01:00

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>
)
}