585 lines
20 KiB
TypeScript
585 lines
20 KiB
TypeScript
"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<string, any>;
|
|
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<EnrichmentType>("CALIBER");
|
|
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
|
|
const [limit, setLimit] = useState<number>(50);
|
|
|
|
const [items, setItems] = useState<QueueItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [busyId, setBusyId] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Bulk selection
|
|
const [selected, setSelected] = useState<Record<number, boolean>>({});
|
|
|
|
// “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<number, EnrichmentStatus>
|
|
>({});
|
|
|
|
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<number, boolean> = {};
|
|
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<number, EnrichmentStatus> = {};
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Controls */}
|
|
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="text-sm text-zinc-400">
|
|
Showing <span className="text-zinc-200 font-medium">{count}</span>
|
|
</div>
|
|
|
|
<select
|
|
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
|
value={type}
|
|
onChange={(e) => setType(e.target.value as EnrichmentType)}
|
|
>
|
|
<option value="CALIBER">CALIBER</option>
|
|
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
|
|
</select>
|
|
|
|
<select
|
|
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
|
value={status}
|
|
onChange={(e) => {
|
|
setSelected({});
|
|
setLocalStatus({});
|
|
setStatus(e.target.value as EnrichmentStatus);
|
|
}}
|
|
>
|
|
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
|
|
<option value="APPROVED">APPROVED</option>
|
|
<option value="REJECTED">REJECTED</option>
|
|
<option value="APPLIED">APPLIED</option>
|
|
</select>
|
|
|
|
<select
|
|
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
|
value={limit}
|
|
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
|
|
>
|
|
<option value={25}>25</option>
|
|
<option value={50}>50</option>
|
|
<option value={100}>100</option>
|
|
<option value={200}>200</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={load}
|
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
|
/>
|
|
Refresh
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={runRules}
|
|
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
|
disabled={loading}
|
|
title="Run enrichment rules to create new suggestions"
|
|
>
|
|
<Wand2 className="h-4 w-4" />
|
|
Run Rules
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={runAi}
|
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
|
|
disabled={loading}
|
|
title="Run AI enrichment to create new suggestions"
|
|
>
|
|
<Wand2 className="h-4 w-4" />
|
|
Run AI
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk action bar */}
|
|
{selectedIds.length > 0 ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
|
<div className="text-sm text-zinc-300">
|
|
Selected{" "}
|
|
<span className="font-semibold">{selectedIds.length}</span>
|
|
<span className="ml-2 text-xs text-zinc-500">
|
|
(Apply only works on APPROVED + product caliber blank)
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => bulk("approve")}
|
|
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
disabled={loading}
|
|
>
|
|
Approve Selected
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => bulk("reject")}
|
|
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
disabled={loading}
|
|
>
|
|
Reject Selected
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => bulk("apply")}
|
|
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
|
disabled={loading}
|
|
title="Applies only APPROVED rows where product caliber is blank"
|
|
>
|
|
Apply Selected
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelected({})}
|
|
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
|
disabled={loading}
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Table */}
|
|
<div className="overflow-hidden rounded-md border border-zinc-800">
|
|
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
|
<div className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={allOnPageSelected}
|
|
onChange={toggleAllOnPage}
|
|
className="h-4 w-4 accent-amber-400"
|
|
aria-label="Select all"
|
|
/>
|
|
</div>
|
|
<div>Product</div>
|
|
<div className="text-right">Confidence</div>
|
|
<div className="text-right">Suggested</div>
|
|
<div className="text-right">Actions</div>
|
|
</div>
|
|
|
|
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
|
|
{items.length === 0 ? (
|
|
<div className="p-6 text-sm text-zinc-400">No items found.</div>
|
|
) : (
|
|
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 (
|
|
<div
|
|
key={it.id}
|
|
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
|
|
>
|
|
<div className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!selected[it.id]}
|
|
onChange={() => toggleOne(it.id)}
|
|
className="h-4 w-4 accent-amber-400"
|
|
aria-label={`Select enrichment ${it.id}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<div className="flex items-start gap-3">
|
|
{it.mainImageUrl ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={it.mainImageUrl}
|
|
alt=""
|
|
className="h-10 w-10 rounded border border-zinc-800 object-cover"
|
|
/>
|
|
) : (
|
|
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
|
|
)}
|
|
|
|
<div className="min-w-0">
|
|
<a
|
|
href={`/admin/products/${it.productId}`}
|
|
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
|
|
>
|
|
{it.productName ?? `Product #${it.productId}`}
|
|
</a>
|
|
|
|
<div className="mt-0.5 text-xs text-zinc-500">
|
|
{it.brandName ? `${it.brandName} • ` : ""}
|
|
{it.source} • {st}
|
|
{alreadySet ? (
|
|
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
|
|
Already set: {alreadySetValue}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{it.rationale ? (
|
|
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
|
|
{it.rationale}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-right text-sm font-semibold text-zinc-200">
|
|
{(it.confidence ?? 0).toFixed(2)}
|
|
</div>
|
|
|
|
<div className="text-right text-sm text-amber-300">
|
|
{suggested ?? "—"}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
{st === "PENDING_REVIEW" && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => act(it.id, "approve")}
|
|
disabled={isBusy || alreadySet}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
|
title={
|
|
alreadySet
|
|
? "Product caliber already set"
|
|
: "Approve"
|
|
}
|
|
aria-label="Approve"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => act(it.id, "reject")}
|
|
disabled={isBusy}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
|
title="Reject"
|
|
aria-label="Reject"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{st === "APPROVED" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => act(it.id, "apply")}
|
|
disabled={isBusy || alreadySet}
|
|
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
|
title={
|
|
alreadySet
|
|
? `Already set on product: ${alreadySetValue}`
|
|
: "Apply (writes to product if blank)"
|
|
}
|
|
>
|
|
Apply
|
|
</button>
|
|
)}
|
|
|
|
{st === "APPLIED" && (
|
|
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
|
|
Applied
|
|
</span>
|
|
)}
|
|
|
|
{st === "REJECTED" && (
|
|
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
|
|
Rejected
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |