From c509cb6cd09e67907b3730c59c5f52eac614725c Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 9 Jan 2026 10:18:32 -0500 Subject: [PATCH] fixes to admin/products --- app/admin/layout.tsx | 26 +- app/admin/products/_components/BulkBar.tsx | 475 ++++++++++-------- .../products/_components/ProductsToolbar.tsx | 440 ++++++++++++++++ .../products/_components/RightDrawer.tsx | 86 ++++ .../_components/adminProductsClient.tsx | 186 ++++--- .../products/_components/productsTable.tsx | 179 ++++--- app/admin/products/_lib/api.ts | 24 +- app/admin/products/_lib/types.ts | 9 + lib/api/platforms.ts | 4 +- 9 files changed, 1061 insertions(+), 368 deletions(-) create mode 100644 app/admin/products/_components/ProductsToolbar.tsx create mode 100644 app/admin/products/_components/RightDrawer.tsx diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 14534f6..b01090c 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -125,55 +125,45 @@ export default function AdminLayout({ const router = useRouter(); const pathname = usePathname(); - // Where to send people back after login const next = useMemo( () => encodeURIComponent(pathname || "/admin"), [pathname] ); - /** - * ✅ AUTH GUARD - * Redirects happen in useEffect (NOT during render) - */ useEffect(() => { if (loading) return; - // Not logged in if (!user) { router.replace(`/login?next=${next}`); return; } - // Logged in but not admin if (user.role !== "ADMIN") { router.replace("/"); } }, [loading, user, router, next]); - // While loading OR redirecting, render nothing if (loading) return null; if (!user) return null; if (user.role !== "ADMIN") return null; return ( -
+ // ✅ prevent browser-level horizontal scroll from any wide children +
setCollapsed((v) => !v)} groups={navGroups} /> - {/* Main column */} -
- {/* Top bar */} + {/* ✅ min-w-0 is the critical fix: lets the main column shrink */} +

Admin

-

- Battl Builders Control Panel -

+

Battl Control Panel

@@ -186,11 +176,11 @@ export default function AdminLayout({
- {/* Content */} -
+ {/* ✅ min-w-0 here prevents table min-width from widening the page */} +
{children}
); -} +} \ No newline at end of file diff --git a/app/admin/products/_components/BulkBar.tsx b/app/admin/products/_components/BulkBar.tsx index fc6ae50..19c5c79 100644 --- a/app/admin/products/_components/BulkBar.tsx +++ b/app/admin/products/_components/BulkBar.tsx @@ -1,6 +1,8 @@ "use client"; -import type { PlatformDto } from "../_lib/types"; +import { useMemo, useState } from "react"; +import type { CaliberDto, PlatformDto } from "../_lib/types"; +import { RightDrawer } from "./RightDrawer"; type ProductVisibility = "PUBLIC" | "HIDDEN"; type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED"; @@ -33,7 +35,7 @@ export function BulkBar(props: { // options platformOptions: PlatformDto[]; roleOptions: string[]; - caliberOptions: string[]; + caliberOptions: CaliberDto[]; // state bulkPlatformKey: string; @@ -87,219 +89,276 @@ export function BulkBar(props: { runBulk, } = props; + const [open, setOpen] = useState(false); + if (selectedCount <= 0) return null; + const disabled = loading || selectedCount <= 0; + + // one button to apply staged field updates + const applyFieldChanges = () => { + const patch: BulkUpdateSet = { classificationReason: "Admin bulk override" }; + + if (bulkPlatformKey) { + patch.platform = bulkPlatformKey; + if (bulkPlatformLock) patch.platformLocked = true; + } + + if (bulkRole) { + patch.partRole = bulkRole; + patch.partRoleLocked = bulkRoleLock; + } + + if (bulkCaliber) patch.caliber = bulkCaliber; + if (bulkCaliberGroup.trim()) patch.caliberGroup = bulkCaliberGroup.trim(); + if (bulkCaliber || bulkCaliberGroup.trim()) { + patch.caliberLocked = bulkCaliberLock; + patch.forceCaliberUpdate = bulkForceCaliber; + } + + // If user didn't stage anything, do nothing + const keys = Object.keys(patch).filter((k) => k !== "classificationReason"); + if (keys.length === 0) return; + + runBulk(patch); + }; + + const footer = useMemo(() => { + return ( +
+
+ Selected: {selectedCount} +
+ + +
+ ); + }, [applyFieldChanges, disabled, selectedCount]); + return ( -
-
+ <> + {/* Tiny inline bar */} +
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 */} -
- - - - - - - - - - - -
+
-
+ + {/* Drawer: all bulk controls */} + +
+ {/* Set fields */} +
+
+ Set fields +
+ +
+ {/* Platform */} +
+ +
+ + + +
+
+ + {/* Role */} +
+ +
+ + + +
+
+ + {/* Caliber */} +
+ + + + + setBulkCaliberGroup(e.target.value)} + placeholder="Caliber group (optional)" + className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm" + /> + +
+ + + +
+
+
+ +
+ Tip: stage changes above, then use Apply changes. +
+
+ + {/* Quick actions */} +
+
+ Quick actions +
+ +
+ + + + + + + + + +
+
+ + {/* Danger zone */} +
+
+ Danger zone +
+ + +
+
+
+ ); } \ No newline at end of file diff --git a/app/admin/products/_components/ProductsToolbar.tsx b/app/admin/products/_components/ProductsToolbar.tsx new file mode 100644 index 0000000..3679d71 --- /dev/null +++ b/app/admin/products/_components/ProductsToolbar.tsx @@ -0,0 +1,440 @@ +/* eslint-disable */ +"use client"; + +import type { AdminFilters, PlatformDto, CaliberDto } from "../_lib/types"; +import { useMemo, useState } from "react"; +import { RightDrawer } from "./RightDrawer"; +import { Field } from "./ui"; + +type VisibleCols = { + brand: boolean; + platform: boolean; + role: boolean; + caliber: boolean; + import: boolean; + flags: boolean; + updated: boolean; +}; + +function countActiveFilters(draft: AdminFilters) { + const entries: [string, any][] = [ + ["platform", draft.platform], + ["partRole", draft.partRole], + ["caliber", draft.caliber], + ["visibility", draft.visibility], + ["status", draft.status], + ["builderEligible", draft.builderEligible], + ["adminLocked", draft.adminLocked], + ]; + return entries.filter(([, v]) => v !== "" && v != null).length; +} + +export default function ProductsToolbar({ + draft, + setDraft, + platformOptions, + roleOptions, + caliberOptions, + loading, + onApply, + onClear, + selectedCount, + visibleCols, + setVisibleCols, +}: { + draft: AdminFilters; + setDraft: React.Dispatch>; + platformOptions: PlatformDto[]; + roleOptions: string[]; + caliberOptions: CaliberDto[]; + loading: boolean; + onApply: () => void; + onClear: () => void; + selectedCount: number; + + // column toggles + visibleCols: VisibleCols; + setVisibleCols: React.Dispatch>; +}) { + const [open, setOpen] = useState(false); + + const activeCount = useMemo(() => countActiveFilters(draft), [draft]); + + return ( + <> + {/* Top toolbar: stays small even on 14" */} +
+
+ + 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" + /> + + + + + + +
+ +
+
+
+ + {/* Drawer: all the heavy controls */} + + + +
+ +
+
+ } + > +
+ {/* Filters */} +
+ Filters +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* Columns */} +
+
+ Columns +
+ +
+ {/* Presets */} +
+ + + + + +
+ + {/* Toggles */} +
+ + + + + + + + + + + + + +
+ +

+ Product + checkbox are always shown. +

+
+
+ + {/* Bulk actions placeholder */} +
+
+ Bulk actions +
+
+ {selectedCount === 0 + ? "Select rows to enable bulk actions." + : `Ready for bulk actions on ${selectedCount} selected item(s).`} +
+ (Hook your actual bulk controls here next.) +
+
+
+
+ + + ); +} \ No newline at end of file diff --git a/app/admin/products/_components/RightDrawer.tsx b/app/admin/products/_components/RightDrawer.tsx new file mode 100644 index 0000000..82fd3ae --- /dev/null +++ b/app/admin/products/_components/RightDrawer.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Fragment } from "react"; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; + +export function RightDrawer({ + open, + onClose, + title, + children, + footer, + widthClassName = "max-w-lg", +}: { + open: boolean; + onClose: (open: boolean) => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; + widthClassName?: string; +}) { + return ( + + + +
+ + +
+
+
+ + +
+
+ + {title} + + + +
+ +
+ {children} +
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/products/_components/adminProductsClient.tsx b/app/admin/products/_components/adminProductsClient.tsx index 8ba21c1..cbbb3eb 100644 --- a/app/admin/products/_components/adminProductsClient.tsx +++ b/app/admin/products/_components/adminProductsClient.tsx @@ -6,7 +6,10 @@ import type { AdminProductRow, PageResponse, PlatformDto, + CaliberDto, } from "../_lib/types"; + + import { initialFilters } from "../_lib/types"; import { bulkUpdate, @@ -16,11 +19,11 @@ import { 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) +import { BulkBar } from "./BulkBar"; +import ProductsToolbar from "./ProductsToolbar"; export default function AdminProductsClient() { const [page, setPage] = useState(0); @@ -37,8 +40,19 @@ export default function AdminProductsClient() { const [applied, setApplied] = useState(initialFilters); const [roleOptions, setRoleOptions] = useState([""]); - const [caliberOptions, setCaliberOptions] = useState([""]); - + const [caliberOptions, setCaliberOptions] = useState([]); + // ------------------------------ + // Column Collasping + // ------------------------------ + const [visibleCols, setVisibleCols] = useState({ + brand: true, + platform: true, + role: true, + caliber: true, + import: true, + flags: true, + updated: true, + }); // ------------------------------ // Bulk action state // ------------------------------ @@ -69,8 +83,18 @@ export default function AdminProductsClient() { setLoading(true); setErr(null); try { - const res = await fetchAdminProducts(queryParams); - setData(res); + const res: any = await fetchAdminProducts(queryParams); + + // API returns `{ content, page: { size, number, totalElements, totalPages } }` + // but the UI expects `totalPages/totalElements/number/size` at the top-level. + const normalized: any = res?.page + ? { + content: res.content ?? [], + ...res.page, + } + : res; + + setData(normalized); setSelected(new Set()); } catch (e: any) { setErr(e?.message ?? "Failed to load."); @@ -98,10 +122,10 @@ export default function AdminProductsClient() { useEffect(() => { (async () => { try { - const cals = await fetchAdminCalibers({}); - setCaliberOptions(["", ...cals]); + const cals = await fetchAdminCalibers({ activeOnly: true }); + setCaliberOptions(Array.isArray(cals) ? cals : []); } catch { - setCaliberOptions([""]); + setCaliberOptions([]); } })(); }, []); @@ -196,9 +220,11 @@ export default function AdminProductsClient() { }; return ( -
+ // Prevent page-level horizontal scroll; allow table-only horizontal scroll. +
+ {/* Header */}
-
+

Admin · Products

Filter, select, and apply bulk actions. This is where bad imports @@ -207,7 +233,7 @@ export default function AdminProductsClient() {

- - - void clearOne(k)} /> - - {/* Next extraction: BulkBar. For now you can move your existing bulk JSX into BulkBar.tsx */} - {selectedIds.length > 0 && ( - + - )} - {err && ( -
- {err} + {/* Chips stay visible too */} + void clearOne(k)} /> + + {/* Bulk actions */} + {selectedIds.length > 0 && ( + + )} + + {err && ( +
+ {err} +
+ )} +
+ + {/* Table + pagination area + min-w-0 is crucial to stop flex children from forcing horizontal overflow */} +
+ {/* This min-w-0 wrapper ensures the table container can shrink to viewport width. + The ProductsTable itself should have overflow-x-auto on its wrapper and a min-w on the table. */} +
+
- )} - - - + +
); } diff --git a/app/admin/products/_components/productsTable.tsx b/app/admin/products/_components/productsTable.tsx index 4af38d4..5a11962 100644 --- a/app/admin/products/_components/productsTable.tsx +++ b/app/admin/products/_components/productsTable.tsx @@ -1,5 +1,15 @@ import type { AdminProductRow } from "../_lib/types"; +export type ProductsTableVisibleCols = { + brand: boolean; + platform: boolean; + role: boolean; + caliber: boolean; + import: boolean; + flags: boolean; + updated: boolean; +}; + export default function ProductsTable({ rows, loading, @@ -7,6 +17,7 @@ export default function ProductsTable({ onToggleAll, onToggleOne, allOnPageSelected, + visibleCols, }: { rows: AdminProductRow[]; loading: boolean; @@ -14,34 +25,52 @@ export default function ProductsTable({ onToggleAll: () => void; onToggleOne: (id: number) => void; allOnPageSelected: boolean; + visibleCols: ProductsTableVisibleCols; }) { + // Compute colSpan based on visible columns + 2 sticky columns (checkbox + product) + const colSpan = + 2 + + (visibleCols.brand ? 1 : 0) + + (visibleCols.platform ? 1 : 0) + + (visibleCols.role ? 1 : 0) + + (visibleCols.caliber ? 1 : 0) + + (visibleCols.import ? 1 : 0) + + (visibleCols.flags ? 1 : 0) + + (visibleCols.updated ? 1 : 0); + return ( -
- +
+
- - - - - - - - - + + {/* Sticky product col */} + + + {visibleCols.brand && } + {visibleCols.platform && } + {visibleCols.role && } + {visibleCols.caliber && } + {visibleCols.import && } + {visibleCols.flags && } + {visibleCols.updated && } {loading && ( - @@ -49,7 +78,7 @@ export default function ProductsTable({ {!loading && rows.length === 0 && ( - @@ -61,7 +90,8 @@ export default function ProductsTable({ key={row.id} className="border-t border-zinc-900 hover:bg-zinc-950/40" > - - - - - + {visibleCols.brand && ( + + )} - + )} + + {visibleCols.role && ( + + )} + + {visibleCols.caliber && ( + + )} + + {visibleCols.import && ( + + )} + + {visibleCols.flags && ( + - + + )} - - - + {visibleCols.updated && ( + + )} ))}
+ {/* Sticky checkbox col */} + ProductBrandPlatformRoleCaliberImportFlagsUpdated + Product + BrandPlatformRoleCaliberImportFlagsUpdated
+ Loading…
+ No results.
+ {/* Sticky checkbox */} + + {/* Sticky product */} +
{row.mainImageUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -83,11 +114,10 @@ export default function ProductsTable({ )}
-
{row.name}
-
- {/* {row.slug} · */} - #{row.id} +
+ {row.name}
+
#{row.id}
{row.adminNote ? (
📝 {row.adminNote} @@ -97,61 +127,78 @@ export default function ProductsTable({
{row.brandName ?? "—"}{row.platform ?? "—"}{row.partRole ?? "—"}{row.brandName ?? "—"} -
-
- {row.caliber ?? "—"} - {row.caliberLocked ? ( - - LOCK + {visibleCols.platform && ( +
{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.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 ?? "—"} - + {row.updatedAt ?? "—"} +
); -} +} \ No newline at end of file diff --git a/app/admin/products/_lib/api.ts b/app/admin/products/_lib/api.ts index 66b8ee4..5e98c10 100644 --- a/app/admin/products/_lib/api.ts +++ b/app/admin/products/_lib/api.ts @@ -7,6 +7,9 @@ import type { ProductVisibility, } from "./types"; +import type { CaliberDto } from "./types"; + + async function authedFetch(url: string, init?: RequestInit) { const token = getToken(); const res = await fetch(url, { @@ -30,11 +33,15 @@ export async function fetchAdminRoles(params: Record) { return (await res.json()) as string[]; } -export async function fetchAdminCalibers(params: Record) { +// Facet/filtering can still use distinct values seen on products (optional) +export async function fetchProductCaliberFacet(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()}`); + if (!res.ok) + throw new Error( + `Failed to load caliber facet (${res.status}): ${await res.text()}` + ); return (await res.json()) as string[]; } @@ -55,7 +62,7 @@ export async function fetchPlatforms() { 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); + return all.filter((p) => (p as any).is_active ?? (p as any).isActive); } export async function bulkUpdate( @@ -92,4 +99,15 @@ export async function bulkUpdate( if (!res.ok) throw new Error(`Bulk update failed (${res.status}): ${await res.text()}`); return (await res.json()) as { updatedCount: number; skippedLockedCount: number }; +} + + +export async function fetchAdminCalibers(params: Record) { + const res = await authedFetch( + `${API_BASE}/api/v1/admin/calibers?${qs(params)}` + ); + if (!res.ok) { + throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`); + } + return (await res.json()) as CaliberDto[]; } \ No newline at end of file diff --git a/app/admin/products/_lib/types.ts b/app/admin/products/_lib/types.ts index 2b5c26b..db64d5b 100644 --- a/app/admin/products/_lib/types.ts +++ b/app/admin/products/_lib/types.ts @@ -58,4 +58,13 @@ export const initialFilters: AdminFilters = { status: "", builderEligible: "", adminLocked: "", +}; + +export type CaliberDto = { + id: number; + key: string; // canonical key (ex: "5.56 NATO") + label: string; // human-readable label + isActive: boolean; + createdAt?: string; + updatedAt?: string; }; \ No newline at end of file diff --git a/lib/api/platforms.ts b/lib/api/platforms.ts index 1d3b365..a3cd991 100644 --- a/lib/api/platforms.ts +++ b/lib/api/platforms.ts @@ -74,12 +74,12 @@ export async function updatePlatform( dto: UpdatePlatformDto ): Promise { const res = await fetch(`${API_BASE}/api/platforms/${id}`, { - method: "PUT", + method: "PATCH", headers: { "Content-Type": "application/json", ...tokenHeaders, }, - body: JSON.stringify(dto), + body: JSON.stringify({ isActive: dto.isActive }), }); if (!res.ok) {