little bit of ui clean up and fixing the platform labels/keys

This commit is contained in:
2026-01-05 13:47:59 -05:00
parent b7e3db8d34
commit 055ebb4cd9
7 changed files with 601 additions and 326 deletions
+433 -214
View File
@@ -38,6 +38,9 @@ type PageResponse<T> = {
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(
@@ -51,35 +54,15 @@ function getCookie(name: string): string | 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]) => {
@@ -118,6 +101,8 @@ async function bulkUpdate(
builderEligible: boolean;
adminLocked: boolean;
adminNote: string;
platform: string;
platformLocked: boolean;
}>
) {
const token = getToken();
@@ -137,25 +122,101 @@ async function bulkUpdate(
throw new Error(`Bulk update failed (${res.status}): ${txt}`);
}
return (await res.json()) as { updatedCount: number };
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() {
// 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);
// dropdown data
const [platformOptions, setPlatformOptions] = useState<PlatformDto[]>([]);
// data
const [data, setData] = useState<PageResponse<AdminProductRow> | null>(null);
const [loading, setLoading] = useState(false);
@@ -164,30 +225,35 @@ export default function AdminProductsPage() {
// 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 {
q,
platform,
partRole,
visibility,
status,
builderEligible: builderEligible === "" ? null : builderEligible,
adminLocked: adminLocked === "" ? null : adminLocked,
...applied,
builderEligible:
applied.builderEligible === "" ? null : applied.builderEligible,
adminLocked: applied.adminLocked === "" ? null : applied.adminLocked,
page,
size,
sort: "updatedAt,desc",
};
}, [
q,
platform,
partRole,
visibility,
status,
builderEligible,
adminLocked,
page,
size,
]);
}, [applied, page, size]);
const refresh = async () => {
setLoading(true);
@@ -195,8 +261,7 @@ export default function AdminProductsPage() {
try {
const res = await fetchAdminProducts(queryParams);
setData(res);
// clear selection on refresh so you dont accidentally nuke new stuff
setSelected(new Set());
setSelected(new Set()); // prevent accidental “bulk” on new results
} catch (e: any) {
setErr(e?.message ?? "Failed to load.");
} finally {
@@ -204,23 +269,39 @@ export default function AdminProductsPage() {
}
};
// auto-refresh only on paging (kept behavior)
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryParams.page, queryParams.size]); // only auto-refresh on page/size
}, [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));
}
if (allOnPageSelected) content.forEach((r) => next.delete(r.id));
else content.forEach((r) => next.add(r.id));
setSelected(next);
};
@@ -231,29 +312,83 @@ export default function AdminProductsPage() {
setSelected(next);
};
const selectedIds = Array.from(selected);
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;
// 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);
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-7xl px-4 py-10">
<div className="mb-6 flex items-start justify-between gap-4">
<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">
@@ -271,167 +406,251 @@ export default function AdminProductsPage() {
</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"
/>
{/* 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>
<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"
/>
<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>
<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"
/>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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={() => {
setQ("");
setPlatform("");
setPartRole("");
setVisibility("");
setStatus("");
setBuilderEligible("");
setAdminLocked("");
setPage(0);
refresh();
}}
>
Clear
</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>
{/* 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>
{/* 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>
<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 && (
@@ -562,7 +781,7 @@ export default function AdminProductsPage() {
{/* 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}
Page {(data?.number ?? 0) + 1} of {data?.totalPages ?? 1} {" "}
{data?.totalElements ?? 0} total
</div>