import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { Search, Filter, Sparkles, EyeOff, Tag } from 'lucide-react'; import { api } from '../../lib/api-client'; import { AppShell, PageHeader, PageBody } from '../../layout/AppShell'; import { Button, Badge, Card, Input, Label, Select, Skeleton } from '../../components/ui/primitives'; import { Table, THead, TBody, TR, TH, TD } from '../../components/ui/table'; import { ProductDetailDrawer } from './ProductDetailDrawer'; import type { ProductRow, BrandCount } from './types'; const PAGE_SIZE = 50; export function ProductsList() { const qc = useQueryClient(); const [filters, setFilters] = useState({ q: '', brand: '', is_sneaker: '' as '' | 'true' | 'false', in_stock: '' as '' | 'true' | 'false', has_ai_desc: '' as '' | 'true' | 'false', missing_image: '' as '' | 'true' | 'false', }); const [page, setPage] = useState(0); const [selected, setSelected] = useState>(new Set()); const [openProductId, setOpenProductId] = useState(null); const params = useMemo(() => { const p: Record = { limit: PAGE_SIZE, offset: page * PAGE_SIZE }; for (const [k, v] of Object.entries(filters)) if (v) p[k] = v; return p; }, [filters, page]); const { data, isLoading } = useQuery({ queryKey: ['products', params], queryFn: () => api.get<{ products: ProductRow[]; total: number; brands: BrandCount[] }>('/products', params), }); const bulkMut = useMutation({ mutationFn: (body: Record) => api.post<{ updated?: number; queued?: number; jobId?: number }>('/products/bulk', body), onSuccess: (res) => { if (res.jobId) toast.success(`${res.queued} producten in de wachtrij (job #${res.jobId})`); else toast.success(`${res.updated} producten bijgewerkt`); setSelected(new Set()); qc.invalidateQueries({ queryKey: ['products'] }); }, onError: (e: Error) => toast.error(e.message), }); function toggleAll() { if (!data?.products) return; if (selected.size === data.products.length) setSelected(new Set()); else setSelected(new Set(data.products.map((p) => p.id))); } function toggleOne(id: number) { const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); setSelected(next); } const total = data?.total ?? 0; const products = data?.products ?? []; const brands = data?.brands ?? []; const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1); function updateFilter(k: K, v: typeof filters[K]) { setFilters((f) => ({ ...f, [k]: v })); setPage(0); } return (
{selected.size > 0 && (
{selected.size} geselecteerd |
)} {isLoading ? (
{[0, 1, 2, 3, 4].map((i) => ( ))}
) : ( {products.map((p) => ( setOpenProductId(p.id)} className="cursor-pointer"> ))}
0 && selected.size === products.length} onChange={toggleAll} /> Naam Merk Prijs Aanbieders Clicks 7d Status
e.stopPropagation()}> toggleOne(p.id)} /> {p.image_url ? ( ) : (
)}

{p.name}

{p.model_name &&

{p.model_name}

}
{p.brand ?? '—'} {p.lowest_price > 0 ? `€${Number(p.lowest_price).toFixed(2)}` : } {p.offer_count > 0 ? ( {p.in_stock_count}/{p.offer_count} ) : ( )} {p.clicks_7d ?? 0}
{p.is_sneaker && sneaker} {p.has_ai_desc && AI} {p.in_stock_count === 0 && geen stock}
)} {!isLoading && products.length > 0 && (
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} van {total.toLocaleString('nl-NL')}
{page + 1} / {maxPage + 1}
)}
{openProductId && ( setOpenProductId(null)} onSaved={() => qc.invalidateQueries({ queryKey: ['products'] })} /> )} ); } function FilterTri({ label, value, onChange, }: { label: string; value: '' | 'true' | 'false'; onChange: (v: '' | 'true' | 'false') => void; }) { return (
{([['', 'alle'], ['true', 'ja'], ['false', 'nee']] as const).map(([v, l]) => ( ))}
); }