From ae86fe68677d49f3c67546840cce37456aa4e32f Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 8 Jan 2026 15:26:40 -0500 Subject: [PATCH] support for filtering and updating caliber --- app/(app)/vault/page.tsx | 2 +- app/admin/layout.tsx | 78 +- app/admin/products/_components/BulkBar.tsx | 305 +++++++ .../_components/adminProductsClient.tsx | 286 ++++++ app/admin/products/_components/filterBar.tsx | 170 ++++ .../products/_components/filterChips.tsx | 20 + .../products/_components/paginationBar.tsx | 60 ++ .../products/_components/productsTable.tsx | 157 ++++ app/admin/products/_components/types.ts | 6 + app/admin/products/_components/ui.tsx | 31 + app/admin/products/_lib/api.ts | 95 ++ app/admin/products/_lib/auth.ts | 33 + app/admin/products/_lib/types.ts | 61 ++ app/admin/products/page.tsx | 826 +----------------- components/parts/Filters.tsx | 51 +- components/parts/PartsBrowseClient.tsx | 19 +- 16 files changed, 1358 insertions(+), 842 deletions(-) create mode 100644 app/admin/products/_components/BulkBar.tsx create mode 100644 app/admin/products/_components/adminProductsClient.tsx create mode 100644 app/admin/products/_components/filterBar.tsx create mode 100644 app/admin/products/_components/filterChips.tsx create mode 100644 app/admin/products/_components/paginationBar.tsx create mode 100644 app/admin/products/_components/productsTable.tsx create mode 100644 app/admin/products/_components/types.ts create mode 100644 app/admin/products/_components/ui.tsx create mode 100644 app/admin/products/_lib/api.ts create mode 100644 app/admin/products/_lib/auth.ts create mode 100644 app/admin/products/_lib/types.ts diff --git a/app/(app)/vault/page.tsx b/app/(app)/vault/page.tsx index c530e12..4a63dab 100644 --- a/app/(app)/vault/page.tsx +++ b/app/(app)/vault/page.tsx @@ -241,7 +241,7 @@ export default function VaultPage() { }`} title={valid ? "Edit details / publish later" : "Invalid build id"} > - Edit Title + View / Edit diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 4902986..14534f6 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -25,41 +25,91 @@ const navGroups: AdminNavGroup[] = [ { label: "Overview", icon: , - items: [{ label: "Dashboard", href: "/admin", icon: }], + items: [ + { + label: "Dashboard", + href: "/admin", + icon: , + }, + ], }, { label: "Catalog", icon: , items: [ - { label: "Products", href: "/admin/products", icon: }, - { label: "Platforms", href: "/admin/platforms", icon: }, + { + label: "Products", + href: "/admin/products", + icon: , + }, + { + label: "Platforms", + href: "/admin/platforms", + icon: , + }, ], }, { label: "Data Ops", icon: , items: [ - { label: "Imports", href: "/admin/import-status", icon: }, - { label: "Mappings", href: "/admin/mapping", icon: }, - { label: "Enrichment", href: "/admin/enrichment", icon: }, + { + label: "Imports", + href: "/admin/import-status", + icon: , + }, + { + label: "Mappings", + href: "/admin/mapping", + icon: , + }, + { + label: "Enrichment", + href: "/admin/enrichment", + icon: , + }, ], }, { label: "Growth & Comms", icon: , items: [ - { label: "Beta Invites", href: "/admin/beta-invites", icon: }, - { label: "List Emails", href: "/admin/email", icon: }, - { label: "Send an Email", href: "/admin/email/send", icon: }, + { + label: "Beta Invites", + href: "/admin/beta-invites", + icon: , + }, + { + label: "List Emails", + href: "/admin/email", + icon: , + }, + { + label: "Send an Email", + href: "/admin/email/send", + icon: , + }, ], }, { label: "Access & Config", icon: , items: [ - { label: "Users", href: "/admin/users", icon: }, - { label: "Merchants", href: "/admin/merchants", icon: }, - { label: "Settings", href: "/admin/settings", icon: }, + { + label: "Users", + href: "/admin/users", + icon: , + }, + { + label: "Merchants", + href: "/admin/merchants", + icon: , + }, + { + label: "Settings", + href: "/admin/settings", + icon: , + }, ], }, ]; @@ -137,10 +187,10 @@ export default function AdminLayout({ {/* Content */} -
+
{children}
); -} \ No newline at end of file +} diff --git a/app/admin/products/_components/BulkBar.tsx b/app/admin/products/_components/BulkBar.tsx new file mode 100644 index 0000000..fc6ae50 --- /dev/null +++ b/app/admin/products/_components/BulkBar.tsx @@ -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 ( +
+
+
+ Selected: {selectedCount} +
+ +
+ + {/* Platform */} +
+ + + + + +
+ +
+ + {/* Role */} +
+ + + + + +
+ +
+ + {/* Caliber */} +
+ + + setBulkCaliberGroup(e.target.value)} + placeholder="caliber group (optional)" + className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" + /> + + + + + + +
+ + {/* Quick actions */} +
+ + + + + + + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/products/_components/adminProductsClient.tsx b/app/admin/products/_components/adminProductsClient.tsx new file mode 100644 index 0000000..8ba21c1 --- /dev/null +++ b/app/admin/products/_components/adminProductsClient.tsx @@ -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([]); + const [data, setData] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [err, setErr] = useState(null); + + const [selected, setSelected] = useState>(new Set()); + + const [draft, setDraft] = useState(initialFilters); + const [applied, setApplied] = useState(initialFilters); + + const [roleOptions, setRoleOptions] = useState([""]); + const [caliberOptions, setCaliberOptions] = useState([""]); + + // ------------------------------ + // 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[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 ( +
+
+
+

Admin · Products

+

+ Filter, select, and apply bulk actions. This is where bad imports + come to die. +

+
+ + +
+ + + + void clearOne(k)} /> + + {/* Next extraction: BulkBar. For now you can move your existing bulk JSX into BulkBar.tsx */} + {selectedIds.length > 0 && ( + + )} + + {err && ( +
+ {err} +
+ )} + + + + +
+ ); +} diff --git a/app/admin/products/_components/filterBar.tsx b/app/admin/products/_components/filterBar.tsx new file mode 100644 index 0000000..6d8aece --- /dev/null +++ b/app/admin/products/_components/filterBar.tsx @@ -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>; + platformOptions: PlatformDto[]; + roleOptions: string[]; + caliberOptions: string[]; + loading: boolean; + onApply: () => void; + onClear: () => void; +}) { + return ( +
+
+ + 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" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ ); +} diff --git a/app/admin/products/_components/filterChips.tsx b/app/admin/products/_components/filterChips.tsx new file mode 100644 index 0000000..7a89ad2 --- /dev/null +++ b/app/admin/products/_components/filterChips.tsx @@ -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 ( +
+ {chips.map((c) => ( + onClearOne(c.key)} /> + ))} +
+ ); +} \ No newline at end of file diff --git a/app/admin/products/_components/paginationBar.tsx b/app/admin/products/_components/paginationBar.tsx new file mode 100644 index 0000000..503447e --- /dev/null +++ b/app/admin/products/_components/paginationBar.tsx @@ -0,0 +1,60 @@ +export default function PaginationBar({ + page, + setPage, + size, + setSize, + loading, + totalPages, + totalElements, + number, + }: { + page: number; + setPage: React.Dispatch>; + size: number; + setSize: React.Dispatch>; + loading: boolean; + totalPages: number; + totalElements: number; + number: number; + }) { + return ( +
+
+ Page {number + 1} of {totalPages} • {totalElements} total +
+ +
+ + + + + +
+
+ ); + } \ No newline at end of file diff --git a/app/admin/products/_components/productsTable.tsx b/app/admin/products/_components/productsTable.tsx new file mode 100644 index 0000000..4af38d4 --- /dev/null +++ b/app/admin/products/_components/productsTable.tsx @@ -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; + onToggleAll: () => void; + onToggleOne: (id: number) => void; + allOnPageSelected: boolean; +}) { + return ( +
+ + + + + + + + + + + + + + + + + {loading && ( + + + + )} + + {!loading && rows.length === 0 && ( + + + + )} + + {!loading && + rows.map((row) => ( + + + + + + + + + + + + + + + + + ))} + +
+ + ProductBrandPlatformRoleCaliberImportFlagsUpdated
+ Loading… +
+ No results. +
+ onToggleOne(row.id)} + /> + +
+ {row.mainImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + +
+
{row.name}
+
+ {/* {row.slug} · */} + #{row.id} +
+ {row.adminNote ? ( +
+ 📝 {row.adminNote} +
+ ) : null} +
+
+
{row.brandName ?? "—"}{row.platform ?? "—"}{row.partRole ?? "—"} +
+
+ {row.caliber ?? "—"} + {row.caliberLocked ? ( + + LOCK + + ) : null} +
+ {row.caliberGroup ? ( +
+ {row.caliberGroup} +
+ ) : null} +
+
{row.importStatus ?? "—"} +
+ {row.visibility === "HIDDEN" ? ( + + HIDDEN + + ) : null} + {row.status !== "ACTIVE" ? ( + + {row.status} + + ) : null} + {row.builderEligible === false ? ( + + NO_BUILDER + + ) : null} + {row.adminLocked ? ( + + LOCKED + + ) : null} +
+
+ {row.updatedAt ?? "—"} +
+
+ ); +} diff --git a/app/admin/products/_components/types.ts b/app/admin/products/_components/types.ts new file mode 100644 index 0000000..a9fbc8a --- /dev/null +++ b/app/admin/products/_components/types.ts @@ -0,0 +1,6 @@ +export type PlatformDto = { + id: number; + key: string; + label: string; + is_active: boolean; + }; \ No newline at end of file diff --git a/app/admin/products/_components/ui.tsx b/app/admin/products/_components/ui.tsx new file mode 100644 index 0000000..ab44ba5 --- /dev/null +++ b/app/admin/products/_components/ui.tsx @@ -0,0 +1,31 @@ +export function Field({ + label, + children, + widthClass, + }: { + label: string; + children: React.ReactNode; + widthClass?: string; + }) { + return ( +
+
+ {label} +
+ {children} +
+ ); + } + + export function Chip({ text, onClear }: { text: string; onClear: () => void }) { + return ( + + ); + } \ No newline at end of file diff --git a/app/admin/products/_lib/api.ts b/app/admin/products/_lib/api.ts new file mode 100644 index 0000000..66b8ee4 --- /dev/null +++ b/app/admin/products/_lib/api.ts @@ -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) { + 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) { + 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) { + 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; +} + +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 }; +} \ No newline at end of file diff --git a/app/admin/products/_lib/auth.ts b/app/admin/products/_lib/auth.ts new file mode 100644 index 0000000..19c9272 --- /dev/null +++ b/app/admin/products/_lib/auth.ts @@ -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) { + 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(); +} \ No newline at end of file diff --git a/app/admin/products/_lib/types.ts b/app/admin/products/_lib/types.ts new file mode 100644 index 0000000..2b5c26b --- /dev/null +++ b/app/admin/products/_lib/types.ts @@ -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 = { + 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: "", +}; \ No newline at end of file diff --git a/app/admin/products/page.tsx b/app/admin/products/page.tsx index 55687ea..de80837 100644 --- a/app/admin/products/page.tsx +++ b/app/admin/products/page.tsx @@ -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 = { - 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) { - 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) { - 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; -} - -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(); - 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 ( -
-
- {label} -
- {children} -
- ); -} - -function Chip({ text, onClear }: { text: string; onClear: () => void }) { - return ( - - ); -} - -export default function AdminProductsPage() { - // paging - const [page, setPage] = useState(0); - const [size, setSize] = useState(50); - - // dropdown data - const [platformOptions, setPlatformOptions] = useState([]); - - // data - const [data, setData] = useState | null>(null); - const [loading, setLoading] = useState(false); - const [err, setErr] = useState(null); - - // selection - const [selected, setSelected] = useState>(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(initialFilters); - const [applied, setApplied] = useState(initialFilters); - - // bulk action UI state - const [bulkPlatformKey, setBulkPlatformKey] = useState(""); - 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[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 ( -
-
-
-

Admin · Products

-

- Filter, select, and apply bulk actions. This is where bad imports - come to die. -

-
- - -
- - {/* ROW 1: FILTER BAR */} -
-
- - 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" - /> - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - {chips.length > 0 && ( -
- {chips.map((c) => ( - void clearOne(c.key)} - /> - ))} -
- )} -
- - {/* ROW 2: BULK BAR (only when selection > 0) */} - {selectedIds.length > 0 && ( -
-
-
- Selected:{" "} - {selectedIds.length} -
- -
- -
- - - - - -
- -
- - - - - - - - - - - -
-
-
- )} - - {/* Errors */} - {err && ( -
- {err} -
- )} - - {/* Table */} -
- - - - - - - - - - - - - - - - {loading && ( - - - - )} - - {!loading && content.length === 0 && ( - - - - )} - - {!loading && - content.map((row) => ( - - - - - - - - - - - - - - - ))} - -
- - ProductBrandPlatformRoleImportFlagsUpdated
- Loading… -
- No results. -
- toggleOne(row.id)} - /> - -
- {row.mainImageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- )} - -
-
{row.name}
-
- {row.slug} · #{row.id} -
- {row.adminNote ? ( -
- 📝 {row.adminNote} -
- ) : null} -
-
-
{row.brandName ?? "—"}{row.platform ?? "—"}{row.partRole ?? "—"}{row.importStatus ?? "—"} -
- {row.visibility === "HIDDEN" ? ( - - HIDDEN - - ) : null} - {row.status !== "ACTIVE" ? ( - - {row.status} - - ) : null} - {row.builderEligible === false ? ( - - NO_BUILDER - - ) : null} - {row.adminLocked ? ( - - LOCKED - - ) : null} -
-
- {row.updatedAt ?? "—"} -
-
- - {/* Pagination */} -
-
- Page {(data?.number ?? 0) + 1} of {data?.totalPages ?? 1} •{" "} - {data?.totalElements ?? 0} total -
- -
- - - - - -
-
-
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/components/parts/Filters.tsx b/components/parts/Filters.tsx index 0fd2338..d80f689 100644 --- a/components/parts/Filters.tsx +++ b/components/parts/Filters.tsx @@ -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: { )}
+ {availableCalibers?.length > 0 && ( +
+
+ Caliber +
+ +
+ {availableCalibers.map((c) => { + const checked = caliberFilter.includes(c); + return ( + + ); + })} +
+
+ )} + {/* In-stock toggle */}
); -} \ No newline at end of file +} diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx index 1796ac6..a846e4e 100644 --- a/components/parts/PartsBrowseClient.tsx +++ b/components/parts/PartsBrowseClient.tsx @@ -112,6 +112,7 @@ export default function PartsBrowseClient(props: { const [error, setError] = useState(null); const [brandFilter, setBrandFilter] = useState([]); + const [caliberFilter, setCaliberFilter] = useState([]); const [sortBy, setSortBy] = useState("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}