"use client"; import { useEffect, useMemo, useState } from "react"; import { Check, X, Wand2, RefreshCw } from "lucide-react"; type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED"; type EnrichmentType = "CALIBER" | "CALIBER_GROUP"; type EnrichmentSource = "RULES" | "AI"; type QueueItem = { id: number; productId: number; productName?: string; productSlug?: string; mainImageUrl?: string; brandName?: string; enrichmentType: EnrichmentType; source: EnrichmentSource; status: EnrichmentStatus; attributes: Record; confidence: number; rationale?: string; createdAt: string; // NEW: current value on products table (so we can prevent bad applies) productCaliber?: string | null; productCaliberGroup?: string | null; }; const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; function getAuthHeaders(): HeadersInit { if (typeof window === "undefined") return {}; const token = localStorage.getItem("token") || localStorage.getItem("jwt") || localStorage.getItem("accessToken"); return token ? { Authorization: `Bearer ${token}` } : {}; } async function apiFetch(path: string, init?: RequestInit) { const res = await fetch(`${API_BASE}${path}`, { ...init, headers: { "Content-Type": "application/json", ...getAuthHeaders(), ...(init?.headers ?? {}), }, // If you're using cookie auth instead of bearer, flip this on: // credentials: "include", }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `Request failed (${res.status})`); } const ct = res.headers.get("content-type") ?? ""; if (ct.includes("application/json")) return res.json(); return res.text(); } function hasProductCaliber(it: QueueItem) { const c = it.productCaliber; return typeof c === "string" && c.trim().length > 0; } function hasAlreadySet(it: QueueItem) { if (it.enrichmentType === "CALIBER") { return hasProductCaliber(it); } const g = it.productCaliberGroup; return typeof g === "string" && g.trim().length > 0; } // ✅ ADD THIS: text for the “Already set:” pill function alreadySetLabel(it: QueueItem) { return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup; } export default function EnrichmentQueueClient() { const [type, setType] = useState("CALIBER"); const [status, setStatus] = useState("PENDING_REVIEW"); const [limit, setLimit] = useState(50); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); // Bulk selection const [selected, setSelected] = useState>({}); // “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately // so the row swaps buttons without waiting for a reload. const [localStatus, setLocalStatus] = useState< Record >({}); const count = useMemo(() => items.length, [items]); const selectedIds = useMemo( () => Object.entries(selected) .filter(([, v]) => v) .map(([k]) => Number(k)), [selected] ); const allOnPageSelected = useMemo(() => { if (items.length === 0) return false; return items.every((it) => selected[it.id]); }, [items, selected]); function toggleAllOnPage() { const next = { ...selected }; const nextValue = !allOnPageSelected; for (const it of items) next[it.id] = nextValue; setSelected(next); } function toggleOne(id: number) { setSelected((prev) => ({ ...prev, [id]: !prev[id] })); } function effectiveStatus(it: QueueItem): EnrichmentStatus { return localStatus[it.id] ?? it.status; } async function load() { setLoading(true); setError(null); try { const data = (await apiFetch( `/api/admin/enrichment/queue2?type=${encodeURIComponent( type )}&status=${encodeURIComponent(status)}&limit=${limit}` )) as QueueItem[]; setItems(data ?? []); // Keep selection only for items still visible setSelected((prev) => { const keep = new Set((data ?? []).map((x) => x.id)); const next: Record = {}; for (const [k, v] of Object.entries(prev)) { const id = Number(k); if (keep.has(id)) next[id] = v; } return next; }); // Reset local status for items not in view setLocalStatus((prev) => { const keep = new Set((data ?? []).map((x) => x.id)); const next: Record = {}; for (const [k, v] of Object.entries(prev)) { const id = Number(k); if (keep.has(id)) next[id] = v; } return next; }); } catch (e: any) { setError(e?.message ?? "Failed to load queue"); } finally { setLoading(false); } } async function runRules() { setLoading(true); setError(null); try { await apiFetch( `/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`, { method: "POST" } ); setStatus("PENDING_REVIEW"); setTimeout(load, 50); } catch (e: any) { setError(e?.message ?? "Failed to run rules"); setLoading(false); } } async function runAi() { setLoading(true); setError(null); try { await apiFetch( `/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`, { method: "POST" } ); setStatus("PENDING_REVIEW"); setTimeout(load, 50); } catch (e: any) { setError(e?.message ?? "Failed to run AI"); setLoading(false); } } async function act(id: number, action: "approve" | "reject" | "apply") { setBusyId(id); setError(null); // Optimistic UI for approve/reject so the row instantly changes if (action === "approve") setLocalStatus((p) => ({ ...p, [id]: "APPROVED" })); if (action === "reject") setLocalStatus((p) => ({ ...p, [id]: "REJECTED" })); try { await apiFetch(`/api/admin/enrichment/${id}/${action}`, { method: "POST", }); await load(); } catch (e: any) { // rollback optimistic local status on error setLocalStatus((p) => { const next = { ...p }; delete next[id]; return next; }); setError(e?.message ?? `Failed to ${action}`); } finally { setBusyId(null); } } async function bulk(action: "approve" | "reject" | "apply") { const ids = selectedIds; if (ids.length === 0) return; setLoading(true); setError(null); try { // Apply should only run for items that are APPROVED (effective status) AND product caliber is blank const byId = new Map(items.map((x) => [x.id, x])); const filtered = action === "apply" ? ids.filter((id) => { const it = byId.get(id); if (!it) return false; return ( effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it) ); }) : ids; for (const id of filtered) { // optimistic statuses for approve/reject in bulk if (action === "approve") setLocalStatus((p) => ({ ...p, [id]: "APPROVED" })); if (action === "reject") setLocalStatus((p) => ({ ...p, [id]: "REJECTED" })); await apiFetch(`/api/admin/enrichment/${id}/${action}`, { method: "POST", }); } setSelected({}); await load(); } catch (e: any) { setError(e?.message ?? `Failed to bulk ${action}`); setLoading(false); } finally { setLoading(false); } } useEffect(() => { load(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [type, status, limit]); return (
{/* Controls */}
Showing {count}
{/* Bulk action bar */} {selectedIds.length > 0 ? (
Selected{" "} {selectedIds.length} (Apply only works on APPROVED + product caliber blank)
) : null}
{error ? (
{error}
) : null} {/* Table */}
Product
Confidence
Suggested
Actions
{items.length === 0 ? (
No items found.
) : ( items.map((it) => { const suggested = it.enrichmentType === "CALIBER" ? it.attributes?.caliber : it.attributes?.caliberGroup; const isBusy = busyId === it.id; const st = effectiveStatus(it); const alreadySet = hasAlreadySet(it); const alreadySetValue = alreadySetLabel(it); return (
toggleOne(it.id)} className="h-4 w-4 accent-amber-400" aria-label={`Select enrichment ${it.id}`} />
{it.mainImageUrl ? ( // eslint-disable-next-line @next/next/no-img-element ) : (
)}
{it.productName ?? `Product #${it.productId}`}
{it.brandName ? `${it.brandName} • ` : ""} {it.source} • {st} {alreadySet ? ( Already set: {alreadySetValue} ) : null}
{it.rationale ? (
{it.rationale}
) : null}
{(it.confidence ?? 0).toFixed(2)}
{suggested ?? "—"}
{st === "PENDING_REVIEW" && ( <> )} {st === "APPROVED" && ( )} {st === "APPLIED" && ( Applied )} {st === "REJECTED" && ( Rejected )}
); }) )}
); }