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
+98 -49
View File
@@ -1,14 +1,14 @@
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
LayoutGrid, List, Trash2, X, ArrowUpDown,
CheckSquare, Square, EyeOff, Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { listProducts, deleteProduct } from '../api/products'
import { listProducts, batchDeleteProducts } from '../api/products'
import type { Product } from '../api/products'
import ConfirmModal from '../components/ConfirmModal'
const CATEGORY_LABELS: Record<string, string> = {
TRB: 'TRB',
@@ -123,7 +123,6 @@ export default function ProductLibraryPage() {
const [sortBy, setSortBy] = useState<'pim_id' | 'name' | 'status'>('pim_id')
const [view, setView] = useState<'grid' | 'table'>('grid')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [confirmState, setConfirmState] = useState<{ open: boolean; title: string; message: string; onConfirm: () => void }>({ open: false, title: '', message: '', onConfirm: () => {} })
// Debounce search input (300ms)
useEffect(() => {
@@ -169,31 +168,41 @@ export default function ProductLibraryPage() {
setSelected(allSelected ? new Set() : new Set(products.map((p) => p.id)))
}
// ── Bulk delete ────────────────────────────────────────────────────────
const deleteMut = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => deleteProduct(id, true)))
},
onSuccess: (_, ids) => {
toast.success(`${ids.length} product${ids.length > 1 ? 's' : ''} deleted`)
setSelected(new Set())
qc.invalidateQueries({ queryKey: ['products'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
// ── Bulk actions ──────────────────────────────────────────────────────
const [batchBusy, setBatchBusy] = useState(false)
const [confirmDeleteHard, setConfirmDeleteHard] = useState(false)
const handleDeleteSelected = () => {
const handleDeactivateSelected = async () => {
const ids = [...selected]
if (!ids.length) return
setConfirmState({
open: true,
title: `Delete ${ids.length} product${ids.length > 1 ? 's' : ''}`,
message: 'This cannot be undone.',
onConfirm: () => {
deleteMut.mutate(ids)
setConfirmState((s) => ({ ...s, open: false }))
},
})
setBatchBusy(true)
try {
const result = await batchDeleteProducts(ids, false)
toast.success(`Deactivated ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`)
setSelected(new Set())
qc.invalidateQueries({ queryKey: ['products'] })
} catch (e: any) {
toast.error(e.response?.data?.detail || 'Deactivate failed')
} finally {
setBatchBusy(false)
}
}
const handleHardDeleteSelected = async () => {
const ids = [...selected]
if (!ids.length) return
setBatchBusy(true)
try {
const result = await batchDeleteProducts(ids, true)
toast.success(`Permanently deleted ${result.deleted} product${result.deleted !== 1 ? 's' : ''}`)
setSelected(new Set())
setConfirmDeleteHard(false)
qc.invalidateQueries({ queryKey: ['products'] })
} catch (e: any) {
toast.error(e.response?.data?.detail || 'Delete failed')
} finally {
setBatchBusy(false)
}
}
return (
@@ -204,9 +213,21 @@ export default function ProductLibraryPage() {
<Library size={22} className="text-accent" />
<div>
<h1 className="text-2xl font-bold text-content">Product Library</h1>
<p className="text-sm text-content-muted">
{products ? `${products.length} products` : isLoading ? 'Loading' : '0 products'}
</p>
<div className="flex items-center gap-3 text-sm text-content-muted">
<span>{products ? `${products.length} products` : isLoading ? 'Loading\u2026' : '0 products'}</span>
{products && products.length > 0 && (
<button
onClick={toggleAll}
className="flex items-center gap-1 hover:text-content transition-colors"
>
{allSelected ? <CheckSquare size={13} /> : <Square size={13} />}
{allSelected ? 'Deselect all' : 'Select all'}
</button>
)}
{selected.size > 0 && (
<span className="text-accent font-medium">{selected.size} selected</span>
)}
</div>
</div>
</div>
@@ -433,35 +454,63 @@ export default function ProductLibraryPage() {
</div>
)}
<ConfirmModal
open={confirmState.open}
title={confirmState.title}
message={confirmState.message}
onConfirm={confirmState.onConfirm}
onCancel={() => setConfirmState((s) => ({ ...s, open: false }))}
/>
{/* ── Floating action bar ───────────────────────────────────────── */}
{/* ── Floating selection action bar (MediaBrowser pattern) ───── */}
{selected.size > 0 && (
<div
className="fixed bottom-6 z-50 flex items-center gap-4 px-5 py-3 bg-gray-900 text-white rounded-2xl shadow-2xl ring-1 ring-white/10"
style={{ left: 'calc(240px + (100vw - 240px) / 2)', transform: 'translateX(-50%)' }}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 px-5 py-3 rounded-xl shadow-2xl border border-border-default"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
>
<span className="text-sm font-medium">
{selected.size} selected
<span className="text-sm font-medium text-content">
{selected.size} product{selected.size !== 1 ? 's' : ''} selected
</span>
<div className="w-px h-5 bg-border-default" />
<button
onClick={handleDeleteSelected}
disabled={deleteMut.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-600 hover:bg-red-700 rounded text-sm font-medium transition-colors disabled:opacity-50"
onClick={handleDeactivateSelected}
disabled={batchBusy}
className="flex items-center gap-1.5 text-sm font-medium text-content-secondary hover:text-content transition-colors disabled:opacity-50"
>
<Trash2 size={14} />
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
{batchBusy && !confirmDeleteHard
? <><Loader2 size={14} className="animate-spin" /> Deactivating&hellip;</>
: <><EyeOff size={14} /> Deactivate</>
}
</button>
<div className="w-px h-5 bg-border-default" />
{confirmDeleteHard ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-500 font-medium">
Delete {selected.size} permanently?
</span>
<button
onClick={handleHardDeleteSelected}
disabled={batchBusy}
className="flex items-center gap-1 text-sm font-medium text-white bg-red-500 hover:bg-red-600 px-2.5 py-1 rounded transition-colors disabled:opacity-50"
>
{batchBusy
? <><Loader2 size={12} className="animate-spin" /> Deleting&hellip;</>
: <><Trash2 size={12} /> Confirm</>
}
</button>
<button
onClick={() => setConfirmDeleteHard(false)}
disabled={batchBusy}
className="text-sm text-content-muted hover:text-content transition-colors disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDeleteHard(true)}
className="flex items-center gap-1.5 text-sm font-medium text-red-500 hover:text-red-400 transition-colors"
>
<Trash2 size={14} /> Delete permanently
</button>
)}
<div className="w-px h-5 bg-border-default" />
<button
onClick={() => setSelected(new Set())}
className="flex items-center gap-1 px-2 py-1.5 text-gray-400 hover:text-white text-sm transition-colors"
title="Clear selection"
onClick={() => { setSelected(new Set()); setConfirmDeleteHard(false) }}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} /> Clear
</button>