feat: material alias seeds expansion, bulk product delete, dashboard stats widgets

- Material alias seeds: 95 → 855 aliases covering German variants, DIN standards,
  Werkstoffnummern, industry terms, English equivalents, polymer abbreviations
- Batch product delete/deactivate endpoint (POST /products/batch-delete)
- Multi-select UI on Products page with floating action bar
- Dashboard: RenderThroughput + MaterialCoverage widgets
- Dashboard stats endpoint (GET /admin/dashboard-stats)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:45:41 +01:00
parent 4f4a128e08
commit f0dd952f63
10 changed files with 1470 additions and 54 deletions
@@ -21,6 +21,8 @@ const WIDGET_LABELS: Record<WidgetType, string> = {
TopProducts: 'Top 10 Products',
OrdersByUser: 'Orders by User',
RenderBackendStats: 'Render Backend Stats',
RenderThroughput: 'Render Throughput',
MaterialCoverage: 'Material & Product Coverage',
}
const OPERATIONAL_TYPES: WidgetType[] = [
@@ -29,6 +31,8 @@ const OPERATIONAL_TYPES: WidgetType[] = [
'RecentRenders',
'CostOverview',
'WorkerStatus',
'RenderThroughput',
'MaterialCoverage',
]
const ANALYTICS_TYPES: WidgetType[] = [
@@ -25,6 +25,8 @@ import OutputTypeUsageWidget from './widgets/OutputTypeUsageWidget'
import TopProductsWidget from './widgets/TopProductsWidget'
import OrdersByUserWidget from './widgets/OrdersByUserWidget'
import RenderBackendStatsWidget from './widgets/RenderBackendStatsWidget'
import RenderThroughputWidget from './widgets/RenderThroughputWidget'
import MaterialCoverageWidget from './widgets/MaterialCoverageWidget'
const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }> = {
ProductionStats: { title: 'Production Stats', icon: <BarChart2 size={15} /> },
@@ -42,6 +44,8 @@ const WIDGET_META: Record<WidgetType, { title: string; icon: React.ReactNode }>
TopProducts: { title: 'Top 10 Products', icon: <Table2 size={15} /> },
OrdersByUser: { title: 'Orders by User', icon: <Users size={15} /> },
RenderBackendStats: { title: 'Render Backend Stats', icon: <Server size={15} /> },
RenderThroughput: { title: 'Render Throughput', icon: <TrendingUp size={15} /> },
MaterialCoverage: { title: 'Material & Product Coverage', icon: <PieChart size={15} /> },
}
// Analytics widget types that need a timeframe
@@ -77,6 +81,8 @@ function WidgetBody({ type }: { type: WidgetType }) {
case 'TopProducts': return <TopProductsWidget />
case 'OrdersByUser': return <OrdersByUserWidget />
case 'RenderBackendStats': return <RenderBackendStatsWidget />
case 'RenderThroughput': return <RenderThroughputWidget />
case 'MaterialCoverage': return <MaterialCoverageWidget />
default: return <p className="text-xs text-content-muted">Unknown widget</p>
}
}
@@ -0,0 +1,138 @@
import { useQuery } from '@tanstack/react-query'
import { Layers, Package, FileBox, AlertTriangle, CheckCircle2, BookOpen, Link2 } from 'lucide-react'
import { getDashboardStats } from '../../../api/dashboard'
function Skeleton() {
return (
<div className="animate-pulse space-y-3">
<div className="h-6 rounded bg-surface-muted" />
<div className="h-3 rounded-full bg-surface-muted" />
<div className="grid grid-cols-2 gap-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-16 rounded-lg bg-surface-muted" />
))}
</div>
</div>
)
}
function ProgressBar({ pct, color }: { pct: number; color: string }) {
return (
<div className="w-full h-2 rounded-full bg-surface-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${Math.min(pct, 100)}%`, backgroundColor: color }}
/>
</div>
)
}
export default function MaterialCoverageWidget() {
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: getDashboardStats,
staleTime: 30_000,
retry: 1,
})
if (isLoading) return <Skeleton />
if (error) return <p className="text-xs text-red-500">Failed to load material coverage</p>
if (!data) return null
const mc = data.material_coverage
const ps = data.product_stats
const os = data.order_status
return (
<div className="space-y-4">
{/* Material coverage bar */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-content-secondary">Material Coverage</span>
<span className="text-xs font-bold text-content">{mc.coverage_pct}%</span>
</div>
<ProgressBar pct={mc.coverage_pct} color={mc.coverage_pct >= 90 ? '#22c55e' : mc.coverage_pct >= 70 ? '#eab308' : '#ef4444'} />
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-content-muted">{mc.mapped_materials} mapped</span>
{mc.unmapped_materials > 0 && (
<span className="text-[10px] text-amber-600 flex items-center gap-0.5">
<AlertTriangle size={10} />
{mc.unmapped_materials} unmapped
</span>
)}
</div>
</div>
{/* STEP coverage bar */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-content-secondary">STEP File Coverage</span>
<span className="text-xs font-bold text-content">{ps.step_coverage_pct}%</span>
</div>
<ProgressBar pct={ps.step_coverage_pct} color={ps.step_coverage_pct >= 90 ? '#22c55e' : ps.step_coverage_pct >= 70 ? '#eab308' : '#ef4444'} />
<div className="flex items-center justify-between mt-1">
<span className="text-[10px] text-content-muted">{ps.with_step_files} / {ps.total_products} products</span>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-2">
<StatCard icon={<Layers size={14} className="text-blue-500" />} label="Total Materials" value={mc.total_unique_materials} />
<StatCard icon={<BookOpen size={14} className="text-indigo-500" />} label="Library Materials" value={mc.library_material_count} />
<StatCard icon={<Link2 size={14} className="text-teal-500" />} label="Aliases" value={mc.alias_count} />
<StatCard icon={<Package size={14} className="text-purple-500" />} label="Products" value={ps.total_products} />
</div>
{/* Order status summary */}
<div>
<p className="text-xs font-medium text-content-secondary mb-2">Orders ({os.total})</p>
<div className="flex gap-1 h-5 rounded-full overflow-hidden">
{os.completed > 0 && <StatusSlice count={os.completed} total={os.total} color="#22c55e" label="Completed" />}
{os.processing > 0 && <StatusSlice count={os.processing} total={os.total} color="#3b82f6" label="Processing" />}
{os.submitted > 0 && <StatusSlice count={os.submitted} total={os.total} color="#eab308" label="Submitted" />}
{os.draft > 0 && <StatusSlice count={os.draft} total={os.total} color="#9ca3af" label="Draft" />}
{os.rejected > 0 && <StatusSlice count={os.rejected} total={os.total} color="#ef4444" label="Rejected" />}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1.5">
{os.completed > 0 && <LegendItem color="#22c55e" label="Completed" count={os.completed} />}
{os.processing > 0 && <LegendItem color="#3b82f6" label="Processing" count={os.processing} />}
{os.submitted > 0 && <LegendItem color="#eab308" label="Submitted" count={os.submitted} />}
{os.draft > 0 && <LegendItem color="#9ca3af" label="Draft" count={os.draft} />}
{os.rejected > 0 && <LegendItem color="#ef4444" label="Rejected" count={os.rejected} />}
</div>
</div>
</div>
)
}
function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
return (
<div
className="rounded-lg border border-border-default p-2.5"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<div className="flex items-center gap-1.5 mb-1">{icon}</div>
<p className="text-lg font-bold text-content">{value}</p>
<p className="text-[10px] text-content-muted">{label}</p>
</div>
)
}
function StatusSlice({ count, total, color, label }: { count: number; total: number; color: string; label: string }) {
const pct = total > 0 ? (count / total) * 100 : 0
return (
<div
title={`${label}: ${count}`}
style={{ width: `${pct}%`, backgroundColor: color, minWidth: pct > 0 ? '4px' : '0' }}
/>
)
}
function LegendItem({ color, label, count }: { color: string; label: string; count: number }) {
return (
<span className="flex items-center gap-1 text-[10px] text-content-muted">
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: color }} />
{label} ({count})
</span>
)
}
@@ -0,0 +1,85 @@
import { useQuery } from '@tanstack/react-query'
import { CheckCircle2, XCircle, Clock, CalendarDays, CalendarRange, Timer } from 'lucide-react'
import { getDashboardStats } from '../../../api/dashboard'
function Skeleton() {
return (
<div className="animate-pulse space-y-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="h-12 rounded-lg bg-surface-muted" />
))}
</div>
)
}
function formatTime(seconds: number | null): string {
if (seconds == null) return '--'
if (seconds < 60) return `${seconds.toFixed(1)}s`
const m = Math.floor(seconds / 60)
const s = Math.round(seconds % 60)
return `${m}m ${s}s`
}
export default function RenderThroughputWidget() {
const { data, isLoading, error } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: getDashboardStats,
staleTime: 30_000,
retry: 1,
})
if (isLoading) return <Skeleton />
if (error) return <p className="text-xs text-red-500">Failed to load render throughput</p>
if (!data) return null
const t = data.render_throughput
const periods = [
{ label: 'Today', completed: t.completed_today, failed: t.failed_today, icon: <Clock size={14} className="text-blue-500" /> },
{ label: 'This Week', completed: t.completed_this_week, failed: t.failed_this_week, icon: <CalendarDays size={14} className="text-indigo-500" /> },
{ label: 'This Month', completed: t.completed_this_month, failed: t.failed_this_month, icon: <CalendarRange size={14} className="text-purple-500" /> },
]
return (
<div className="space-y-2">
{periods.map(({ label, completed, failed, icon }) => (
<div
key={label}
className="flex items-center justify-between rounded-lg border border-border-default p-3"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm text-content-secondary">{label}</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-sm font-semibold text-green-600">
<CheckCircle2 size={13} />
{completed}
</span>
{failed > 0 && (
<span className="flex items-center gap-1 text-sm font-semibold text-red-500">
<XCircle size={13} />
{failed}
</span>
)}
</div>
</div>
))}
{/* Render time stats */}
<div
className="flex items-center justify-between rounded-lg border border-border-default p-3"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<div className="flex items-center gap-2">
<Timer size={14} className="text-amber-500" />
<span className="text-sm text-content-secondary">Avg / Median</span>
</div>
<span className="text-sm font-semibold text-content">
{formatTime(t.avg_render_time_s)} / {formatTime(t.median_render_time_s)}
</span>
</div>
</div>
)
}