605 lines
19 KiB
TypeScript
605 lines
19 KiB
TypeScript
"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";
|
||
|
||
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;
|
||
|
||
// 1) Prefer localStorage (your canonical token store)
|
||
const ls = localStorage.getItem("ballistic_auth_token");
|
||
if (ls && ls.trim().length > 0) return ls;
|
||
|
||
// 2) Fallback to cookie (only works if NOT HttpOnly)
|
||
const ck = getCookie("bb_access_token");
|
||
if (ck && ck.trim().length > 0) return ck;
|
||
|
||
return null;
|
||
}
|
||
|
||
function getRole(): string | null {
|
||
if (typeof window === "undefined") return null;
|
||
|
||
const lsUser = localStorage.getItem("ballistic_auth_user");
|
||
if (lsUser) {
|
||
try {
|
||
const parsed = JSON.parse(lsUser);
|
||
// adjust if your user object uses a different key
|
||
return parsed?.role ?? parsed?.roles?.[0] ?? null;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const ckRole = getCookie("bb_role");
|
||
return ckRole ?? 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;
|
||
}>
|
||
) {
|
||
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 };
|
||
}
|
||
|
||
export default function AdminProductsPage() {
|
||
// filters
|
||
const [q, setQ] = useState("");
|
||
const [platform, setPlatform] = useState("");
|
||
const [partRole, setPartRole] = useState("");
|
||
const [visibility, setVisibility] = useState<ProductVisibility | "">("");
|
||
const [status, setStatus] = useState<ProductStatus | "">("");
|
||
const [builderEligible, setBuilderEligible] = useState<"" | "true" | "false">(
|
||
""
|
||
);
|
||
const [adminLocked, setAdminLocked] = useState<"" | "true" | "false">("");
|
||
|
||
// paging
|
||
const [page, setPage] = useState(0);
|
||
const [size, setSize] = useState(50);
|
||
|
||
// 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());
|
||
|
||
const queryParams = useMemo(() => {
|
||
return {
|
||
q,
|
||
platform,
|
||
partRole,
|
||
visibility,
|
||
status,
|
||
builderEligible: builderEligible === "" ? null : builderEligible,
|
||
adminLocked: adminLocked === "" ? null : adminLocked,
|
||
page,
|
||
size,
|
||
sort: "updatedAt,desc",
|
||
};
|
||
}, [
|
||
q,
|
||
platform,
|
||
partRole,
|
||
visibility,
|
||
status,
|
||
builderEligible,
|
||
adminLocked,
|
||
page,
|
||
size,
|
||
]);
|
||
|
||
const refresh = async () => {
|
||
setLoading(true);
|
||
setErr(null);
|
||
try {
|
||
const res = await fetchAdminProducts(queryParams);
|
||
setData(res);
|
||
// clear selection on refresh so you don’t accidentally nuke new stuff
|
||
setSelected(new Set());
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? "Failed to load.");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
refresh();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [queryParams.page, queryParams.size]); // only auto-refresh on page/size
|
||
|
||
const content = data?.content ?? [];
|
||
|
||
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 selectedIds = Array.from(selected);
|
||
|
||
const runBulk = async (set: Parameters<typeof bulkUpdate>[1]) => {
|
||
if (selectedIds.length === 0) return;
|
||
|
||
// no confirmations per your “keep moving” vibe, but you can add one later
|
||
setLoading(true);
|
||
setErr(null);
|
||
|
||
try {
|
||
const res = await bulkUpdate(selectedIds, set);
|
||
// eslint-disable-next-line no-console
|
||
console.log("Bulk updated:", res.updatedCount);
|
||
await refresh();
|
||
} catch (e: any) {
|
||
setErr(e?.message ?? "Bulk update failed.");
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-7xl px-4 py-10">
|
||
<div className="mb-6 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>
|
||
|
||
{/* Filters */}
|
||
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-6">
|
||
<input
|
||
value={q}
|
||
onChange={(e) => setQ(e.target.value)}
|
||
placeholder="Search (name, slug, mpn, upc)…"
|
||
className="md:col-span-2 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
/>
|
||
|
||
<input
|
||
value={platform}
|
||
onChange={(e) => setPlatform(e.target.value)}
|
||
placeholder="Platform (AR-15)…"
|
||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
/>
|
||
|
||
<input
|
||
value={partRole}
|
||
onChange={(e) => setPartRole(e.target.value)}
|
||
placeholder="Part role (barrel)…"
|
||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
/>
|
||
|
||
<select
|
||
value={visibility}
|
||
onChange={(e) => setVisibility(e.target.value as any)}
|
||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Visibility (any)</option>
|
||
<option value="PUBLIC">PUBLIC</option>
|
||
<option value="HIDDEN">HIDDEN</option>
|
||
</select>
|
||
|
||
<select
|
||
value={status}
|
||
onChange={(e) => setStatus(e.target.value as any)}
|
||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Status (any)</option>
|
||
<option value="ACTIVE">ACTIVE</option>
|
||
<option value="DISABLED">DISABLED</option>
|
||
<option value="ARCHIVED">ARCHIVED</option>
|
||
</select>
|
||
|
||
<div className="md:col-span-6 flex flex-wrap items-center gap-3">
|
||
<select
|
||
value={builderEligible}
|
||
onChange={(e) => setBuilderEligible(e.target.value as any)}
|
||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Builder eligible (any)</option>
|
||
<option value="true">true</option>
|
||
<option value="false">false</option>
|
||
</select>
|
||
|
||
<select
|
||
value={adminLocked}
|
||
onChange={(e) => setAdminLocked(e.target.value as any)}
|
||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||
>
|
||
<option value="">Admin locked (any)</option>
|
||
<option value="true">true</option>
|
||
<option value="false">false</option>
|
||
</select>
|
||
|
||
<button
|
||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||
onClick={() => {
|
||
setPage(0);
|
||
refresh();
|
||
}}
|
||
disabled={loading}
|
||
>
|
||
Apply filters
|
||
</button>
|
||
|
||
<button
|
||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
|
||
onClick={() => {
|
||
setQ("");
|
||
setPlatform("");
|
||
setPartRole("");
|
||
setVisibility("");
|
||
setStatus("");
|
||
setBuilderEligible("");
|
||
setAdminLocked("");
|
||
setPage(0);
|
||
refresh();
|
||
}}
|
||
>
|
||
Clear
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bulk actions */}
|
||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||
<div className="text-sm opacity-70">
|
||
Selected:{" "}
|
||
<span className="font-semibold opacity-100">
|
||
{selectedIds.length}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="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 || selectedIds.length === 0}
|
||
onClick={() =>
|
||
runBulk({
|
||
visibility: "HIDDEN",
|
||
builderEligible: false,
|
||
adminLocked: true,
|
||
adminNote: "Hidden + removed from builder (bulk)",
|
||
})
|
||
}
|
||
>
|
||
Hide + Remove from Builder + 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 || selectedIds.length === 0}
|
||
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 || selectedIds.length === 0}
|
||
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 || selectedIds.length === 0}
|
||
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 || selectedIds.length === 0}
|
||
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 || selectedIds.length === 0}
|
||
onClick={() => runBulk({ adminLocked: false })}
|
||
>
|
||
Unlock
|
||
</button>
|
||
</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>
|
||
);
|
||
}
|