support for filtering and updating caliber
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type PlatformDto = {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user