support for filtering and updating caliber

This commit is contained in:
2026-01-08 15:26:40 -05:00
parent 72d8f559c9
commit ae86fe6867
16 changed files with 1358 additions and 842 deletions
+1 -1
View File
@@ -241,7 +241,7 @@ export default function VaultPage() {
}`}
title={valid ? "Edit details / publish later" : "Invalid build id"}
>
Edit Title
View / Edit
</Link>
</div>
</li>
+64 -14
View File
@@ -25,41 +25,91 @@ const navGroups: AdminNavGroup[] = [
{
label: "Overview",
icon: <LayoutDashboard className="h-4 w-4" />,
items: [{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> }],
items: [
{
label: "Dashboard",
href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />,
},
],
},
{
label: "Catalog",
icon: <FolderKanban className="h-4 w-4" />,
items: [
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
],
},
{
label: "Data Ops",
icon: <Download className="h-4 w-4" />,
items: [
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
{
label: "Imports",
href: "/admin/import-status",
icon: <Download className="h-4 w-4" />,
},
{
label: "Mappings",
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Enrichment",
href: "/admin/enrichment",
icon: <Wand2 className="h-4 w-4" />,
},
],
},
{
label: "Growth & Comms",
icon: <Mail className="h-4 w-4" />,
items: [
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <Mail className="h-4 w-4" /> },
{ label: "List Emails", href: "/admin/email", icon: <Mail className="h-4 w-4" /> },
{ label: "Send an Email", href: "/admin/email/send", icon: <Mail className="h-4 w-4" /> },
{
label: "Beta Invites",
href: "/admin/beta-invites",
icon: <Mail className="h-4 w-4" />,
},
{
label: "List Emails",
href: "/admin/email",
icon: <Mail className="h-4 w-4" />,
},
{
label: "Send an Email",
href: "/admin/email/send",
icon: <Mail className="h-4 w-4" />,
},
],
},
{
label: "Access & Config",
icon: <Shield className="h-4 w-4" />,
items: [
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
],
},
];
@@ -137,10 +187,10 @@ export default function AdminLayout({
</header>
{/* Content */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
<main className="flex w-full flex-1 flex-col px-4 py-6">
{children}
</main>
</div>
</div>
);
}
}
+305
View File
@@ -0,0 +1,305 @@
"use client";
import type { PlatformDto } from "../_lib/types";
type ProductVisibility = "PUBLIC" | "HIDDEN";
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
type BulkUpdateSet = Partial<{
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string;
platform: string;
platformLocked: boolean;
caliber: string;
caliberGroup: string;
caliberLocked: boolean;
forceCaliberUpdate: boolean;
partRole: string;
partRoleLocked: boolean;
classificationReason: "Admin bulk override";
}>;
export function BulkBar(props: {
selectedCount: number;
loading: boolean;
// options
platformOptions: PlatformDto[];
roleOptions: string[];
caliberOptions: string[];
// state
bulkPlatformKey: string;
setBulkPlatformKey: (v: string) => void;
bulkPlatformLock: boolean;
setBulkPlatformLock: (v: boolean) => void;
bulkRole: string;
setBulkRole: (v: string) => void;
bulkRoleLock: boolean;
setBulkRoleLock: (v: boolean) => void;
bulkCaliber: string;
setBulkCaliber: (v: string) => void;
bulkCaliberGroup: string;
setBulkCaliberGroup: (v: string) => void;
bulkCaliberLock: boolean;
setBulkCaliberLock: (v: boolean) => void;
bulkForceCaliber: boolean;
setBulkForceCaliber: (v: boolean) => void;
// action
runBulk: (set: BulkUpdateSet) => void;
}) {
const {
selectedCount,
loading,
platformOptions,
roleOptions,
caliberOptions,
bulkPlatformKey,
setBulkPlatformKey,
bulkPlatformLock,
setBulkPlatformLock,
bulkRole,
setBulkRole,
bulkRoleLock,
setBulkRoleLock,
bulkCaliber,
setBulkCaliber,
bulkCaliberGroup,
setBulkCaliberGroup,
bulkCaliberLock,
setBulkCaliberLock,
bulkForceCaliber,
setBulkForceCaliber,
runBulk,
} = props;
if (selectedCount <= 0) return null;
return (
<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">{selectedCount}</span>
</div>
<div className="h-4 w-px bg-zinc-800" />
{/* Platform */}
<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="h-4 w-px bg-zinc-800" />
{/* Role */}
<div className="flex flex-wrap items-center gap-2">
<select
value={bulkRole}
onChange={(e) => setBulkRole(e.target.value)}
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Set role</option>
{roleOptions
.filter((r) => r)
.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<label className="flex items-center gap-2 text-xs opacity-80">
<input
type="checkbox"
checked={bulkRoleLock}
onChange={(e) => setBulkRoleLock(e.target.checked)}
/>
Lock role
</label>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || bulkRole.length === 0}
onClick={() =>
runBulk({
partRole: bulkRole,
partRoleLocked: bulkRoleLock,
classificationReason: "Admin bulk override",
})
}
>
Apply Role
</button>
</div>
<div className="h-4 w-px bg-zinc-800" />
{/* Caliber */}
<div className="flex flex-wrap items-center gap-2">
<select
value={bulkCaliber}
onChange={(e) => setBulkCaliber(e.target.value)}
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Set caliber</option>
{caliberOptions
.filter((c) => c)
.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<input
value={bulkCaliberGroup}
onChange={(e) => setBulkCaliberGroup(e.target.value)}
placeholder="caliber group (optional)"
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
/>
<label className="flex items-center gap-2 text-xs opacity-80">
<input
type="checkbox"
checked={bulkCaliberLock}
onChange={(e) => setBulkCaliberLock(e.target.checked)}
/>
Lock caliber
</label>
<label className="flex items-center gap-2 text-xs opacity-80">
<input
type="checkbox"
checked={bulkForceCaliber}
onChange={(e) => setBulkForceCaliber(e.target.checked)}
/>
Force update locked
</label>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || bulkCaliber.length === 0}
onClick={() =>
runBulk({
caliber: bulkCaliber,
...(bulkCaliberGroup.trim()
? { caliberGroup: bulkCaliberGroup.trim() }
: {}),
caliberLocked: bulkCaliberLock,
forceCaliberUpdate: bulkForceCaliber,
classificationReason: "Admin bulk override",
})
}
>
Apply Caliber
</button>
</div>
{/* Quick actions */}
<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>
);
}
@@ -0,0 +1,286 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type {
AdminFilters,
AdminProductRow,
PageResponse,
PlatformDto,
} from "../_lib/types";
import { initialFilters } from "../_lib/types";
import {
bulkUpdate,
fetchAdminCalibers,
fetchAdminProducts,
fetchAdminRoles,
fetchPlatforms,
} from "../_lib/api";
import FilterBar from "./filterBar";
import FilterChips from "./filterChips";
import ProductsTable from "./productsTable";
import PaginationBar from "./paginationBar";
import { BulkBar } from "./BulkBar"; // (you can extract next; keep inline for now)
export default function AdminProductsClient() {
const [page, setPage] = useState(0);
const [size, setSize] = useState(50);
const [platformOptions, setPlatformOptions] = useState<PlatformDto[]>([]);
const [data, setData] = useState<PageResponse<AdminProductRow> | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [draft, setDraft] = useState<AdminFilters>(initialFilters);
const [applied, setApplied] = useState<AdminFilters>(initialFilters);
const [roleOptions, setRoleOptions] = useState<string[]>([""]);
const [caliberOptions, setCaliberOptions] = useState<string[]>([""]);
// ------------------------------
// Bulk action state
// ------------------------------
const [bulkPlatformKey, setBulkPlatformKey] = useState("");
const [bulkPlatformLock, setBulkPlatformLock] = useState(false);
const [bulkRole, setBulkRole] = useState("");
const [bulkRoleLock, setBulkRoleLock] = useState(false);
const [bulkCaliber, setBulkCaliber] = useState("");
const [bulkCaliberGroup, setBulkCaliberGroup] = useState("");
const [bulkCaliberLock, setBulkCaliberLock] = useState(false);
const [bulkForceCaliber, setBulkForceCaliber] = 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());
} catch (e: any) {
setErr(e?.message ?? "Failed to load.");
} finally {
setLoading(false);
}
};
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryParams]);
useEffect(() => {
(async () => {
try {
const roles = await fetchAdminRoles({});
setRoleOptions(["", ...roles]);
} catch {
setRoleOptions([""]);
}
})();
}, []);
useEffect(() => {
(async () => {
try {
const cals = await fetchAdminCalibers({});
setCaliberOptions(["", ...cals]);
} catch {
setCaliberOptions([""]);
}
})();
}, []);
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 ?? [];
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 = () => {
setPage(0);
setApplied({ ...draft });
};
const clearFilters = () => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(0);
};
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.caliber)
out.push({ key: "caliber", label: `Caliber: ${applied.caliber}` });
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) => {
setDraft((d) => ({ ...d, [key]: "" } as AdminFilters));
setApplied((a) => ({ ...a, [key]: "" } as AdminFilters));
setPage(0);
};
const runBulk = async (set: Parameters<typeof bulkUpdate>[1]) => {
if (selectedIds.length === 0) return;
setLoading(true);
setErr(null);
try {
await bulkUpdate(selectedIds, set);
await refresh();
} catch (e: any) {
setErr(e?.message ?? "Bulk update failed.");
setLoading(false);
}
};
return (
<div className="w-full px-4 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>
<FilterBar
draft={draft}
setDraft={setDraft}
platformOptions={platformOptions}
roleOptions={roleOptions}
caliberOptions={caliberOptions}
loading={loading}
onApply={applyFilters}
onClear={clearFilters}
/>
<FilterChips chips={chips} onClearOne={(k) => void clearOne(k)} />
{/* Next extraction: BulkBar. For now you can move your existing bulk JSX into BulkBar.tsx */}
{selectedIds.length > 0 && (
<BulkBar
selectedCount={selectedIds.length}
loading={loading}
platformOptions={platformOptions}
roleOptions={roleOptions}
caliberOptions={caliberOptions}
bulkPlatformKey={bulkPlatformKey}
setBulkPlatformKey={setBulkPlatformKey}
bulkPlatformLock={bulkPlatformLock}
setBulkPlatformLock={setBulkPlatformLock}
bulkRole={bulkRole}
setBulkRole={setBulkRole}
bulkRoleLock={bulkRoleLock}
setBulkRoleLock={setBulkRoleLock}
bulkCaliber={bulkCaliber}
setBulkCaliber={setBulkCaliber}
bulkCaliberGroup={bulkCaliberGroup}
setBulkCaliberGroup={setBulkCaliberGroup}
bulkCaliberLock={bulkCaliberLock}
setBulkCaliberLock={setBulkCaliberLock}
bulkForceCaliber={bulkForceCaliber}
setBulkForceCaliber={setBulkForceCaliber}
runBulk={runBulk}
/>
)}
{err && (
<div className="mb-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
{err}
</div>
)}
<ProductsTable
rows={content}
loading={loading}
selected={selected}
onToggleAll={toggleAllOnPage}
onToggleOne={toggleOne}
allOnPageSelected={allOnPageSelected}
/>
<PaginationBar
page={page}
setPage={setPage}
size={size}
setSize={setSize}
loading={loading}
totalPages={data?.totalPages ?? 1}
totalElements={data?.totalElements ?? 0}
number={data?.number ?? 0}
/>
</div>
);
}
@@ -0,0 +1,170 @@
import type { AdminFilters, PlatformDto } from "../_lib/types";
import { Field } from "./ui";
export default function FilterBar({
draft,
setDraft,
platformOptions,
roleOptions,
caliberOptions,
loading,
onApply,
onClear,
}: {
draft: AdminFilters;
setDraft: React.Dispatch<React.SetStateAction<AdminFilters>>;
platformOptions: PlatformDto[];
roleOptions: string[];
caliberOptions: string[];
loading: boolean;
onApply: () => void;
onClear: () => void;
}) {
return (
<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="Caliber">
<select
value={draft.caliber}
onChange={(e) =>
setDraft((s) => ({ ...s, caliber: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="__UNSET__"> Not set </option>
{caliberOptions
.filter((c) => c)
.map((c) => (
<option key={c} value={c}>
{c}
</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={onApply}
disabled={loading}
>
Apply
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
onClick={onClear}
disabled={loading}
>
Clear
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import type { AdminFilters } from "../_lib/types";
import { Chip } from "./ui";
export default function FilterChips({
chips,
onClearOne,
}: {
chips: { key: keyof AdminFilters; label: string }[];
onClearOne: (key: keyof AdminFilters) => void;
}) {
if (chips.length === 0) return null;
return (
<div className="mt-3 flex flex-wrap gap-2">
{chips.map((c) => (
<Chip key={c.key} text={c.label} onClear={() => onClearOne(c.key)} />
))}
</div>
);
}
@@ -0,0 +1,60 @@
export default function PaginationBar({
page,
setPage,
size,
setSize,
loading,
totalPages,
totalElements,
number,
}: {
page: number;
setPage: React.Dispatch<React.SetStateAction<number>>;
size: number;
setSize: React.Dispatch<React.SetStateAction<number>>;
loading: boolean;
totalPages: number;
totalElements: number;
number: number;
}) {
return (
<div className="mt-4 flex items-center justify-between gap-3">
<div className="text-sm opacity-70">
Page {number + 1} of {totalPages} {totalElements} 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 || page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}
@@ -0,0 +1,157 @@
import type { AdminProductRow } from "../_lib/types";
export default function ProductsTable({
rows,
loading,
selected,
onToggleAll,
onToggleOne,
allOnPageSelected,
}: {
rows: AdminProductRow[];
loading: boolean;
selected: Set<number>;
onToggleAll: () => void;
onToggleOne: (id: number) => void;
allOnPageSelected: boolean;
}) {
return (
<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={onToggleAll}
/>
</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">Caliber</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 && rows.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
No results.
</td>
</tr>
)}
{!loading &&
rows.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={() => onToggleOne(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">
<div className="flex flex-col">
<div className="text-sm">
{row.caliber ?? "—"}
{row.caliberLocked ? (
<span className="ml-2 rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] opacity-80">
LOCK
</span>
) : null}
</div>
{row.caliberGroup ? (
<div className="text-xs opacity-60">
{row.caliberGroup}
</div>
) : null}
</div>
</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>
);
}
+6
View File
@@ -0,0 +1,6 @@
export type PlatformDto = {
id: number;
key: string;
label: string;
is_active: boolean;
};
+31
View File
@@ -0,0 +1,31 @@
export 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>
);
}
export 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>
);
}
+95
View File
@@ -0,0 +1,95 @@
import { API_BASE, getToken, qs } from "./auth";
import type {
AdminProductRow,
PageResponse,
PlatformDto,
ProductStatus,
ProductVisibility,
} from "./types";
async function authedFetch(url: string, init?: RequestInit) {
const token = getToken();
const res = await fetch(url, {
credentials: "include",
cache: "no-store",
...(init ?? {}),
headers: {
Accept: "application/json",
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
} as any,
});
return res;
}
export async function fetchAdminRoles(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/roles?${qs(params)}`
);
if (!res.ok) throw new Error(`Failed to load roles (${res.status}): ${await res.text()}`);
return (await res.json()) as string[];
}
export async function fetchAdminCalibers(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/calibers?${qs(params)}`
);
if (!res.ok) throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`);
return (await res.json()) as string[];
}
export async function fetchAdminProducts(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products?${qs(params)}`
);
if (!res.ok) throw new Error(`Failed to load admin products (${res.status}): ${await res.text()}`);
return (await res.json()) as PageResponse<AdminProductRow>;
}
export async function fetchPlatforms() {
const res = await fetch(`${API_BASE}/api/platforms`, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) throw new Error(`Failed to load platforms (${res.status}): ${await res.text()}`);
const all = (await res.json()) as PlatformDto[];
return all.filter((p) => p.is_active);
}
export async function bulkUpdate(
productIds: number[],
set: Partial<{
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string;
platform: string;
platformLocked: boolean;
caliber: string;
caliberGroup: string;
caliberLocked: boolean;
forceCaliberUpdate: boolean;
partRole: string;
partRoleLocked: boolean;
classificationReason: "Admin bulk override";
}>
) {
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) throw new Error(`Bulk update failed (${res.status}): ${await res.text()}`);
return (await res.json()) as { updatedCount: number; skippedLockedCount: number };
}
+33
View File
@@ -0,0 +1,33 @@
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export 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;
}
export 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;
}
export 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();
}
+61
View File
@@ -0,0 +1,61 @@
export type ProductVisibility = "PUBLIC" | "HIDDEN";
export type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
export type AdminProductRow = {
id: number;
uuid: string;
name: string;
slug: string;
platform: string | null;
partRole: string | null;
brandName: string | null;
importStatus: string | null;
caliber: string | null;
caliberGroup: string | null;
caliberLocked: boolean;
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string | null;
mainImageUrl: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type PageResponse<T> = {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
};
export type AdminFilters = {
q: string;
platform: string;
partRole: string;
caliber: string;
visibility: ProductVisibility | "";
status: ProductStatus | "";
builderEligible: "" | "true" | "false";
adminLocked: "" | "true" | "false";
};
export type PlatformDto = {
id: number;
key: string;
label: string;
is_active: boolean;
};
export const initialFilters: AdminFilters = {
q: "",
platform: "",
partRole: "",
caliber: "",
visibility: "",
status: "",
builderEligible: "",
adminLocked: "",
};
+4 -822
View File
@@ -1,823 +1,5 @@
"use client";
import AdminProductsClient from "./_components/adminProductsClient";
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>
);
}
export default function Page() {
return <AdminProductsClient />;
}
+47 -4
View File
@@ -8,7 +8,9 @@ export default function Filters(props: {
availableBrands: string[];
brandFilter: string[];
setBrandFilter: (v: string[]) => void;
availableCalibers: string[];
caliberFilter: string[];
setCaliberFilter: (next: string[]) => void;
priceBounds: { min: number | null; max: number | null };
priceRange: { min: number | null; max: number | null };
setPriceRange: (v: { min: number | null; max: number | null }) => void;
@@ -20,6 +22,9 @@ export default function Filters(props: {
availableBrands,
brandFilter,
setBrandFilter,
availableCalibers,
caliberFilter,
setCaliberFilter,
priceBounds,
priceRange,
setPriceRange,
@@ -88,7 +93,10 @@ export default function Filters(props: {
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange({
min: Math.min(value, priceRange.max ?? priceBounds.max ?? value),
min: Math.min(
value,
priceRange.max ?? priceBounds.max ?? value
),
max: priceRange.max ?? priceBounds.max ?? value,
});
}}
@@ -104,7 +112,10 @@ export default function Filters(props: {
const value = Number(e.target.value);
setPriceRange({
min: priceRange.min ?? priceBounds.min ?? value,
max: Math.max(value, priceRange.min ?? priceBounds.min ?? value),
max: Math.max(
value,
priceRange.min ?? priceBounds.min ?? value
),
});
}}
className="w-full"
@@ -168,6 +179,38 @@ export default function Filters(props: {
)}
</div>
{availableCalibers?.length > 0 && (
<div className="mt-4">
<div className="mb-2 text-xs font-semibold tracking-wide text-zinc-300">
Caliber
</div>
<div className="space-y-2">
{availableCalibers.map((c) => {
const checked = caliberFilter.includes(c);
return (
<label
key={c}
className="flex items-center gap-2 text-sm text-zinc-300"
>
<input
type="checkbox"
checked={checked}
onChange={(e) => {
if (e.target.checked)
setCaliberFilter([...caliberFilter, c]);
else
setCaliberFilter(caliberFilter.filter((x) => x !== c));
}}
/>
<span>{c}</span>
</label>
);
})}
</div>
</div>
)}
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
@@ -187,4 +230,4 @@ export default function Filters(props: {
</div>
</aside>
);
}
}
+18 -1
View File
@@ -112,6 +112,7 @@ export default function PartsBrowseClient(props: {
const [error, setError] = useState<string | null>(null);
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [caliberFilter, setCaliberFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState("");
const [priceRange, setPriceRange] = useState<{
@@ -151,7 +152,10 @@ export default function PartsBrowseClient(props: {
if (brandFilter.length)
brandFilter.forEach((b) => search.append("brand", b));
// sort mapping
// server search caliber
if (caliberFilter.length)
caliberFilter.forEach((c) => search.append("caliber", c));
// sort mapping (Spring-style: sort=field,dir)
switch (sortBy) {
case "price-asc":
@@ -222,6 +226,7 @@ export default function PartsBrowseClient(props: {
searchQuery,
sortBy,
brandFilter,
caliberFilter,
]);
// Reset pagination on filters
useEffect(() => {
@@ -230,6 +235,7 @@ export default function PartsBrowseClient(props: {
partRole,
effectivePlatform,
brandFilter,
caliberFilter,
sortBy,
searchQuery,
priceRange,
@@ -252,6 +258,14 @@ export default function PartsBrowseClient(props: {
[parts]
);
const availableCalibers = useMemo(
() =>
Array.from(new Set(parts.map((p: any) => p.caliber)))
.filter(Boolean)
.sort((a, b) => String(a).localeCompare(String(b))),
[parts]
);
const priceBounds = useMemo(() => {
const prices = parts
.map((p) => p.price)
@@ -406,6 +420,9 @@ export default function PartsBrowseClient(props: {
availableBrands={availableBrands}
brandFilter={brandFilter}
setBrandFilter={setBrandFilter}
availableCalibers={availableCalibers}
caliberFilter={caliberFilter}
setCaliberFilter={setCaliberFilter}
priceBounds={priceBounds}
priceRange={priceRange}
setPriceRange={setPriceRange}