Files
shadow-gunbuilder-ai-proto/app/admin/products/page.tsx
T

824 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState } from "react";
type ProductVisibility = "PUBLIC" | "HIDDEN";
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
type AdminProductRow = {
id: number;
uuid: string;
name: string;
slug: string;
platform: string | null;
partRole: string | null;
brandName: string | null;
importStatus: string | null;
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string | null;
mainImageUrl: string | null;
createdAt: string | null;
updatedAt: string | null;
};
type PageResponse<T> = {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
};
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// -----------------------------
// Auth helpers (kept as-is)
// -----------------------------
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(
new RegExp(
"(^| )" + name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + "=([^;]+)"
)
);
return match ? decodeURIComponent(match[2]) : null;
}
function getToken(): string | null {
if (typeof window === "undefined") return null;
const ls = localStorage.getItem("ballistic_auth_token");
if (ls && ls.trim().length > 0) return ls;
const ck = getCookie("bb_access_token");
if (ck && ck.trim().length > 0) return ck;
return null;
}
function qs(params: Record<string, any>) {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === "") return;
sp.set(k, String(v));
});
return sp.toString();
}
async function fetchAdminProducts(params: Record<string, any>) {
const token = getToken();
const res = await fetch(`${API_BASE}/api/v1/admin/products?${qs(params)}`, {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
cache: "no-store",
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Failed to load admin products (${res.status}): ${txt}`);
}
return (await res.json()) as PageResponse<AdminProductRow>;
}
async function bulkUpdate(
productIds: number[],
set: Partial<{
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string;
platform: string;
platformLocked: boolean;
}>
) {
const token = getToken();
const res = await fetch(`${API_BASE}/api/v1/admin/products/bulk`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ productIds, ...set }),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Bulk update failed (${res.status}): ${txt}`);
}
return (await res.json()) as {
updatedCount: number;
skippedLockedCount: number;
};
}
// -----------------------------
// UI types
// -----------------------------
type AdminFilters = {
q: string;
platform: string; // platform KEY
partRole: string;
visibility: ProductVisibility | "";
status: ProductStatus | "";
builderEligible: "" | "true" | "false";
adminLocked: "" | "true" | "false";
};
type PlatformDto = {
id: number;
key: string;
label: string;
is_active: boolean;
};
async function fetchPlatforms() {
const res = await fetch(`${API_BASE}/api/platforms`, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Failed to load platforms (${res.status}): ${txt}`);
}
const all = (await res.json()) as PlatformDto[];
return all.filter((p) => p.is_active);
}
// Replace with an API later if you want.
// For now: derive from the current page results + known “common” roles.
function buildRoleOptions(rows: AdminProductRow[]) {
const set = new Set<string>();
rows.forEach((r) => {
if (r.partRole) set.add(r.partRole);
});
return ["", ...Array.from(set).sort((a, b) => a.localeCompare(b))];
}
// -----------------------------
// Small UI helpers
// -----------------------------
function Field({
label,
children,
widthClass,
}: {
label: string;
children: React.ReactNode;
widthClass?: string;
}) {
return (
<div className={widthClass ?? "w-[180px]"}>
<div className="mb-1 text-[11px] uppercase tracking-[0.16em] text-zinc-500">
{label}
</div>
{children}
</div>
);
}
function Chip({ text, onClear }: { text: string; onClear: () => void }) {
return (
<button
type="button"
onClick={onClear}
className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 hover:bg-zinc-900"
title="Clear filter"
>
{text} <span className="ml-1 text-zinc-500">×</span>
</button>
);
}
export default function AdminProductsPage() {
// paging
const [page, setPage] = useState(0);
const [size, setSize] = useState(50);
// dropdown data
const [platformOptions, setPlatformOptions] = useState<PlatformDto[]>([]);
// data
const [data, setData] = useState<PageResponse<AdminProductRow> | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
// selection
const [selected, setSelected] = useState<Set<number>>(new Set());
// filters: draft vs applied (THIS is the big clarity win)
const initialFilters: AdminFilters = {
q: "",
platform: "",
partRole: "",
visibility: "",
status: "",
builderEligible: "",
adminLocked: "",
};
const [draft, setDraft] = useState<AdminFilters>(initialFilters);
const [applied, setApplied] = useState<AdminFilters>(initialFilters);
// bulk action UI state
const [bulkPlatformKey, setBulkPlatformKey] = useState<string>("");
const [bulkPlatformLock, setBulkPlatformLock] = useState(false);
const queryParams = useMemo(() => {
return {
...applied,
builderEligible:
applied.builderEligible === "" ? null : applied.builderEligible,
adminLocked: applied.adminLocked === "" ? null : applied.adminLocked,
page,
size,
sort: "updatedAt,desc",
};
}, [applied, page, size]);
const refresh = async () => {
setLoading(true);
setErr(null);
try {
const res = await fetchAdminProducts(queryParams);
setData(res);
setSelected(new Set()); // prevent accidental “bulk” on new results
} catch (e: any) {
setErr(e?.message ?? "Failed to load.");
} finally {
setLoading(false);
}
};
// auto-refresh only on paging (kept behavior)
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, size]);
// load platforms
useEffect(() => {
(async () => {
try {
const plats = await fetchPlatforms();
plats.sort((a, b) => a.label.localeCompare(b.label));
setPlatformOptions(plats);
} catch (e: any) {
console.warn(e?.message ?? "Failed to load platforms");
}
})();
}, []);
const content = data?.content ?? [];
// derive role options from current page (good enough v1)
const roleOptions = useMemo(() => buildRoleOptions(content), [content]);
const selectedIds = Array.from(selected);
const allOnPageSelected =
content.length > 0 && content.every((r) => selected.has(r.id));
const toggleAllOnPage = () => {
const next = new Set(selected);
if (allOnPageSelected) content.forEach((r) => next.delete(r.id));
else content.forEach((r) => next.add(r.id));
setSelected(next);
};
const toggleOne = (id: number) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
};
const applyFilters = async () => {
setPage(0);
setApplied({ ...draft });
// keep your “manual” refresh model:
await refresh();
};
const clearFilters = async () => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(0);
await refresh();
};
const runBulk = async (set: Parameters<typeof bulkUpdate>[1]) => {
if (selectedIds.length === 0) return;
setLoading(true);
setErr(null);
try {
const res = await bulkUpdate(selectedIds, set);
console.log(
`Updated ${res.updatedCount}` +
(res.skippedLockedCount > 0
? ` • Skipped ${res.skippedLockedCount} (platform locked)`
: "")
);
await refresh();
if ("platform" in set) {
setBulkPlatformKey("");
setBulkPlatformLock(false);
}
} catch (e: any) {
setErr(e?.message ?? "Bulk update failed.");
setLoading(false);
}
};
// active filter chips (optional but huge clarity)
const chips = useMemo(() => {
const out: { key: keyof AdminFilters; label: string }[] = [];
if (applied.q) out.push({ key: "q", label: `Search: ${applied.q}` });
if (applied.platform)
out.push({ key: "platform", label: `Platform: ${applied.platform}` });
if (applied.partRole)
out.push({ key: "partRole", label: `Role: ${applied.partRole}` });
if (applied.visibility)
out.push({
key: "visibility",
label: `Visibility: ${applied.visibility}`,
});
if (applied.status)
out.push({ key: "status", label: `Status: ${applied.status}` });
if (applied.builderEligible)
out.push({
key: "builderEligible",
label: `Builder: ${applied.builderEligible}`,
});
if (applied.adminLocked)
out.push({ key: "adminLocked", label: `Locked: ${applied.adminLocked}` });
return out;
}, [applied]);
const clearOne = async (key: keyof AdminFilters) => {
const nextDraft = { ...draft, [key]: "" } as AdminFilters;
const nextApplied = { ...applied, [key]: "" } as AdminFilters;
setDraft(nextDraft);
setApplied(nextApplied);
setPage(0);
await refresh();
};
return (
<div className="mx-auto max-w-screen-2xl px-2 py-6">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Admin · Products</h1>
<p className="text-sm opacity-70">
Filter, select, and apply bulk actions. This is where bad imports
come to die.
</p>
</div>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
onClick={() => refresh()}
disabled={loading}
>
Refresh
</button>
</div>
{/* ROW 1: FILTER BAR */}
<div className="mb-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="flex flex-wrap items-end gap-2">
<Field label="Search" widthClass="min-w-[260px] flex-1">
<input
value={draft.q}
onChange={(e) => setDraft((s) => ({ ...s, q: e.target.value }))}
placeholder="name, slug, mpn, upc…"
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
/>
</Field>
<Field label="Platform">
<select
value={draft.platform}
onChange={(e) =>
setDraft((s) => ({ ...s, platform: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
{platformOptions.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
</select>
</Field>
<Field label="Role">
<select
value={draft.partRole}
onChange={(e) =>
setDraft((s) => ({ ...s, partRole: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
{roleOptions.map((r) => (
<option key={r || "any"} value={r}>
{r || "Any"}
</option>
))}
</select>
</Field>
<Field label="Visibility">
<select
value={draft.visibility}
onChange={(e) =>
setDraft((s) => ({ ...s, visibility: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="PUBLIC">PUBLIC</option>
<option value="HIDDEN">HIDDEN</option>
</select>
</Field>
<Field label="Status">
<select
value={draft.status}
onChange={(e) =>
setDraft((s) => ({ ...s, status: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="ACTIVE">ACTIVE</option>
<option value="DISABLED">DISABLED</option>
<option value="ARCHIVED">ARCHIVED</option>
</select>
</Field>
<Field label="Builder">
<select
value={draft.builderEligible}
onChange={(e) =>
setDraft((s) => ({
...s,
builderEligible: e.target.value as any,
}))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="true">Eligible</option>
<option value="false">Not eligible</option>
</select>
</Field>
<Field label="Locked">
<select
value={draft.adminLocked}
onChange={(e) =>
setDraft((s) => ({ ...s, adminLocked: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="true">Locked</option>
<option value="false">Unlocked</option>
</select>
</Field>
<div className="ml-auto flex items-center gap-2">
<button
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
onClick={applyFilters}
disabled={loading}
>
Apply
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
onClick={clearFilters}
disabled={loading}
>
Clear
</button>
</div>
</div>
{chips.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{chips.map((c) => (
<Chip
key={c.key}
text={c.label}
onClear={() => void clearOne(c.key)}
/>
))}
</div>
)}
</div>
{/* ROW 2: BULK BAR (only when selection > 0) */}
{selectedIds.length > 0 && (
<div className="mb-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="flex flex-wrap items-center gap-3">
<div className="text-sm opacity-80">
Selected:{" "}
<span className="font-semibold">{selectedIds.length}</span>
</div>
<div className="h-4 w-px bg-zinc-800" />
<div className="flex flex-wrap items-center gap-2">
<select
value={bulkPlatformKey}
onChange={(e) => setBulkPlatformKey(e.target.value)}
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Set platform</option>
{platformOptions.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
<label className="flex items-center gap-2 text-xs opacity-80">
<input
type="checkbox"
checked={bulkPlatformLock}
onChange={(e) => setBulkPlatformLock(e.target.checked)}
/>
Lock platform
</label>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || bulkPlatformKey.length === 0}
onClick={() =>
runBulk({
platform: bulkPlatformKey,
...(bulkPlatformLock ? { platformLocked: true } : {}),
})
}
>
Apply Platform
</button>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() =>
runBulk({
visibility: "HIDDEN",
builderEligible: false,
adminLocked: true,
adminNote: "Hidden + removed from builder (bulk)",
})
}
>
Hide + Remove + Lock
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ builderEligible: false })}
>
Remove from Builder
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ visibility: "HIDDEN" })}
>
Hide
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ status: "DISABLED" })}
>
Disable
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ adminLocked: true })}
>
Lock
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ adminLocked: false })}
>
Unlock
</button>
</div>
</div>
</div>
)}
{/* Errors */}
{err && (
<div className="mb-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
{err}
</div>
)}
{/* Table */}
<div className="overflow-x-auto rounded-md border border-zinc-800">
<table className="w-full text-sm">
<thead className="bg-zinc-950">
<tr className="text-left">
<th className="w-10 px-3 py-2">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
/>
</th>
<th className="px-3 py-2">Product</th>
<th className="px-3 py-2">Brand</th>
<th className="px-3 py-2">Platform</th>
<th className="px-3 py-2">Role</th>
<th className="px-3 py-2">Import</th>
<th className="px-3 py-2">Flags</th>
<th className="px-3 py-2">Updated</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
Loading
</td>
</tr>
)}
{!loading && content.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
No results.
</td>
</tr>
)}
{!loading &&
content.map((row) => (
<tr
key={row.id}
className="border-t border-zinc-900 hover:bg-zinc-950/40"
>
<td className="px-3 py-2">
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => toggleOne(row.id)}
/>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-3">
{row.mainImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={row.mainImageUrl}
alt=""
className="h-10 w-10 rounded object-cover border border-zinc-800"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
)}
<div className="min-w-0">
<div className="truncate font-medium">{row.name}</div>
<div className="truncate text-xs opacity-60">
{row.slug} · #{row.id}
</div>
{row.adminNote ? (
<div className="truncate text-xs opacity-60">
📝 {row.adminNote}
</div>
) : null}
</div>
</div>
</td>
<td className="px-3 py-2">{row.brandName ?? "—"}</td>
<td className="px-3 py-2">{row.platform ?? "—"}</td>
<td className="px-3 py-2">{row.partRole ?? "—"}</td>
<td className="px-3 py-2">{row.importStatus ?? "—"}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1 text-xs">
{row.visibility === "HIDDEN" ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
HIDDEN
</span>
) : null}
{row.status !== "ACTIVE" ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
{row.status}
</span>
) : null}
{row.builderEligible === false ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
NO_BUILDER
</span>
) : null}
{row.adminLocked ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
LOCKED
</span>
) : null}
</div>
</td>
<td className="px-3 py-2 text-xs opacity-70">
{row.updatedAt ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="mt-4 flex items-center justify-between gap-3">
<div className="text-sm opacity-70">
Page {(data?.number ?? 0) + 1} of {data?.totalPages ?? 1} {" "}
{data?.totalElements ?? 0} total
</div>
<div className="flex items-center gap-2">
<select
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
setPage(0);
}}
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}/page
</option>
))}
</select>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Prev
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || (data ? page >= data.totalPages - 1 : true)}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
</div>
);
}