feat: initial commit
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Library, Search, Box, CheckCircle2, Clock, AlertTriangle,
|
||||
LayoutGrid, List, Trash2, X,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listProducts, deleteProduct } from '../api/products'
|
||||
import type { Product } from '../api/products'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
TRB: 'TRB',
|
||||
Kugellager: 'Kugellager',
|
||||
CRB: 'CRB',
|
||||
Gleitlager: 'Gleitlager',
|
||||
SRB_TORB: 'SRB/TORB',
|
||||
Linear_schiene: 'Linear',
|
||||
Anschlagplatten: 'Anschlag',
|
||||
}
|
||||
|
||||
function CadStatusChip({ status }: { status: string | null }) {
|
||||
if (!status) return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-muted text-content-muted">no STEP</span>
|
||||
)
|
||||
if (status === 'completed') return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-success-bg text-status-success-text flex items-center gap-1">
|
||||
<CheckCircle2 size={11} /> ready
|
||||
</span>
|
||||
)
|
||||
if (status === 'processing') return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text flex items-center gap-1">
|
||||
<Clock size={11} /> processing
|
||||
</span>
|
||||
)
|
||||
if (status === 'failed') return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-error-bg text-status-error-text flex items-center gap-1">
|
||||
<AlertTriangle size={11} /> failed
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text">{status}</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductCard({ product, onClick, selected, onSelect }: {
|
||||
product: Product
|
||||
onClick: () => void
|
||||
selected: boolean
|
||||
onSelect: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`card cursor-pointer hover:shadow-md transition-shadow overflow-hidden relative ${
|
||||
selected ? 'ring-2 ring-accent' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Checkbox overlay */}
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => onSelect(e.target.checked)}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="h-40 bg-surface-muted flex items-center justify-center overflow-hidden">
|
||||
{(product.render_image_url || product.thumbnail_url) ? (
|
||||
<img
|
||||
src={product.render_image_url || product.thumbnail_url!}
|
||||
alt={product.name || product.pim_id}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Box size={48} className="text-content-muted" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-1">
|
||||
<span className="inline-block text-xs font-mono bg-surface-muted text-content-secondary px-2 py-0.5 rounded">
|
||||
{product.pim_id}
|
||||
</span>
|
||||
|
||||
<p className="font-semibold text-content text-sm leading-tight truncate" title={product.name || ''}>
|
||||
{product.name || <span className="text-content-muted italic">no name</span>}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{product.category_key && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
|
||||
{CATEGORY_LABELS[product.category_key] || product.category_key}
|
||||
</span>
|
||||
)}
|
||||
{product.baureihe && (
|
||||
<span className="text-xs text-content-muted truncate">{product.baureihe}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 flex items-center gap-2">
|
||||
<CadStatusChip status={product.processing_status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProductLibraryPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [hasCadFilter, setHasCadFilter] = useState<string>('')
|
||||
const [materialsFilter, setMaterialsFilter] = useState('')
|
||||
const [view, setView] = useState<'grid' | 'table'>('grid')
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: products, isLoading } = useQuery({
|
||||
queryKey: ['products', { search, categoryFilter, hasCadFilter, materialsFilter }],
|
||||
queryFn: () => listProducts({
|
||||
q: search || undefined,
|
||||
category_key: categoryFilter || undefined,
|
||||
has_cad: hasCadFilter === 'yes' ? true : hasCadFilter === 'no' ? false : undefined,
|
||||
materials_filter: materialsFilter || undefined,
|
||||
limit: 200,
|
||||
}),
|
||||
})
|
||||
|
||||
// ── Selection helpers ──────────────────────────────────────────────────
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev)
|
||||
n.has(id) ? n.delete(id) : n.add(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const allSelected =
|
||||
!!products?.length && products.every((p) => selected.has(p.id))
|
||||
|
||||
const toggleAll = () => {
|
||||
if (!products) return
|
||||
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'),
|
||||
})
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
if (!confirm(`Delete ${ids.length} product${ids.length > 1 ? 's' : ''}? This cannot be undone.`)) return
|
||||
deleteMut.mutate(ids)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<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` : 'Loading\u2026'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex border border-border-default rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setView('grid')}
|
||||
title="Grid view"
|
||||
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
|
||||
view === 'grid'
|
||||
? 'text-white'
|
||||
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={view === 'grid' ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('table')}
|
||||
title="Table view"
|
||||
className={`px-2.5 py-1.5 text-sm flex items-center transition-colors ${
|
||||
view === 'table'
|
||||
? 'text-white'
|
||||
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={view === 'table' ? { backgroundColor: 'var(--color-accent)' } : undefined}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<div className="relative flex-1 min-w-48 max-w-sm">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by PIM-ID or name\u2026"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-border-default rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
|
||||
title="Filter by product category (TRB, Kugellager, CRB, etc.)"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={hasCadFilter}
|
||||
onChange={(e) => setHasCadFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
|
||||
title="Filter by STEP file status — only products with an uploaded STEP file can be rendered"
|
||||
>
|
||||
<option value="">All CAD status</option>
|
||||
<option value="yes">Has STEP</option>
|
||||
<option value="no">No STEP</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={materialsFilter}
|
||||
onChange={(e) => setMaterialsFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border-default rounded-md text-sm focus:outline-none"
|
||||
title="Filter by material assignment status — complete = all CAD parts have a material assigned"
|
||||
>
|
||||
<option value="">All materials</option>
|
||||
<option value="complete">✓ All materials assigned</option>
|
||||
<option value="incomplete">⚠ Incomplete materials</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-16 text-content-muted">Loading products\u2026</div>
|
||||
) : !products?.length ? (
|
||||
<div className="text-center py-16 text-content-muted">
|
||||
<Library size={48} className="mx-auto mb-3 opacity-30" />
|
||||
<p>No products found</p>
|
||||
<p className="text-sm mt-1">Upload an Excel file to populate the library</p>
|
||||
</div>
|
||||
) : view === 'grid' ? (
|
||||
/* ── Grid view ─────────────────────────────────────────────────── */
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
selected={selected.has(product.id)}
|
||||
onSelect={() => toggleOne(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* ── Table view ────────────────────────────────────────────────── */
|
||||
<div className="card overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-default text-left bg-surface-alt">
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
title="Select / deselect all visible products"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 w-16"></th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">PIM-ID</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">Name</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">Category</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">Baureihe</th>
|
||||
<th className="px-4 py-3 font-medium text-content-secondary">CAD Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product) => (
|
||||
<tr
|
||||
key={product.id}
|
||||
className={`border-b border-border-light hover:bg-surface-hover cursor-pointer transition-colors ${
|
||||
selected.has(product.id) ? 'bg-status-success-bg' : ''
|
||||
}`}
|
||||
onClick={() => navigate(`/products/${product.id}`)}
|
||||
>
|
||||
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(product.id)}
|
||||
onChange={() => toggleOne(product.id)}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="w-10 h-10 bg-surface-muted rounded flex items-center justify-center overflow-hidden">
|
||||
{(product.render_image_url || product.thumbnail_url) ? (
|
||||
<img
|
||||
src={product.render_image_url || product.thumbnail_url!}
|
||||
alt=""
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Box size={18} className="text-content-muted" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-content-secondary">
|
||||
{product.pim_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-content max-w-48 truncate" title={product.name || ''}>
|
||||
{product.name || <span className="text-content-muted italic">no name</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{product.category_key && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-accent-light text-accent font-medium">
|
||||
{CATEGORY_LABELS[product.category_key] || product.category_key}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-content-secondary text-xs max-w-40 truncate" title={product.baureihe || ''}>
|
||||
{product.baureihe || '\u2014'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<CadStatusChip status={product.processing_status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Floating action bar ───────────────────────────────────────── */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 ml-[120px] bg-gray-900 text-white rounded-lg shadow-xl px-5 py-3 flex items-center gap-4 z-50">
|
||||
<span className="text-sm font-medium">
|
||||
{selected.size} selected
|
||||
</span>
|
||||
<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"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{deleteMut.isPending ? 'Deleting\u2026' : 'Delete'}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<X size={14} /> Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user