diff --git a/app/admin/enrichment/EnrichmentQueueClient.tsx b/app/admin/enrichment/EnrichmentQueueClient.tsx new file mode 100644 index 0000000..c75f8ba --- /dev/null +++ b/app/admin/enrichment/EnrichmentQueueClient.tsx @@ -0,0 +1,585 @@ +"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 ?? "http://localhost:8080"; + +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 + + )} +
+
+ ); + }) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/enrichment/page.tsx b/app/admin/enrichment/page.tsx new file mode 100644 index 0000000..65b290a --- /dev/null +++ b/app/admin/enrichment/page.tsx @@ -0,0 +1,18 @@ +import EnrichmentQueueClient from "./EnrichmentQueueClient"; + +export const dynamic = "force-dynamic"; + +export default function AdminEnrichmentPage() { + return ( +
+
+

Enrichment Queue

+

+ Review AI/rules suggestions, approve/reject, then apply to products. +

+
+ + +
+ ); +} \ No newline at end of file diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 331ab2b..804a4ff 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -11,6 +11,7 @@ import { Users, Settings, LucideMail, + Wand2, } from "lucide-react"; const navItems = [ @@ -44,6 +45,11 @@ const navItems = [ href: "/admin/platforms", icon: , }, + { + label: "Enrichment", + href: "/admin/enrichment", + icon: , + }, { label: "Users", href: "/admin/users", @@ -64,6 +70,7 @@ const navItems = [ href: "/admin/email/send", icon: , }, + ]; // ... existing code ... diff --git a/components/AdminLeftNavigation.tsx b/components/AdminLeftNavigation.tsx index c8df0cb..5a5d1bb 100644 --- a/components/AdminLeftNavigation.tsx +++ b/components/AdminLeftNavigation.tsx @@ -10,6 +10,7 @@ import { Users, Settings, LucideMail, + Wand2, } from "lucide-react"; import Link from "next/link"; @@ -49,6 +50,11 @@ const navItems = [ href: "/admin/platforms", icon: , }, + { + label: "Enrichment", + href: "/admin/enrichment", + icon: , + }, { label: "Users", href: "/admin/users",