fixes to admin/products
This commit is contained in:
+8
-18
@@ -125,55 +125,45 @@ export default function AdminLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Where to send people back after login
|
|
||||||
const next = useMemo(
|
const next = useMemo(
|
||||||
() => encodeURIComponent(pathname || "/admin"),
|
() => encodeURIComponent(pathname || "/admin"),
|
||||||
[pathname]
|
[pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* ✅ AUTH GUARD
|
|
||||||
* Redirects happen in useEffect (NOT during render)
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
// Not logged in
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.replace(`/login?next=${next}`);
|
router.replace(`/login?next=${next}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in but not admin
|
|
||||||
if (user.role !== "ADMIN") {
|
if (user.role !== "ADMIN") {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
}, [loading, user, router, next]);
|
}, [loading, user, router, next]);
|
||||||
|
|
||||||
// While loading OR redirecting, render nothing
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
if (user.role !== "ADMIN") return null;
|
if (user.role !== "ADMIN") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-black text-zinc-50">
|
// ✅ prevent browser-level horizontal scroll from any wide children
|
||||||
|
<div className="flex min-h-screen bg-black text-zinc-50 overflow-x-hidden">
|
||||||
<AdminLeftNavigation
|
<AdminLeftNavigation
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
groups={navGroups}
|
groups={navGroups}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main column */}
|
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
|
||||||
<div className="flex min-h-screen flex-1 flex-col">
|
<div className="flex min-h-screen flex-1 min-w-0 flex-col">
|
||||||
{/* Top bar */}
|
|
||||||
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
|
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||||
Admin
|
Admin
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-zinc-200">
|
<p className="text-sm text-zinc-200">Battl Control Panel</p>
|
||||||
Battl Builders Control Panel
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -186,11 +176,11 @@ export default function AdminLayout({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
|
||||||
<main className="flex w-full flex-1 flex-col px-4 py-6">
|
<main className="flex w-full flex-1 min-w-0 flex-col px-4 py-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"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 ProductVisibility = "PUBLIC" | "HIDDEN";
|
||||||
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
|
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
|
||||||
@@ -33,7 +35,7 @@ export function BulkBar(props: {
|
|||||||
// options
|
// options
|
||||||
platformOptions: PlatformDto[];
|
platformOptions: PlatformDto[];
|
||||||
roleOptions: string[];
|
roleOptions: string[];
|
||||||
caliberOptions: string[];
|
caliberOptions: CaliberDto[];
|
||||||
|
|
||||||
// state
|
// state
|
||||||
bulkPlatformKey: string;
|
bulkPlatformKey: string;
|
||||||
@@ -87,219 +89,276 @@ export function BulkBar(props: {
|
|||||||
runBulk,
|
runBulk,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
if (selectedCount <= 0) return null;
|
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 (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm text-white/60">
|
||||||
|
Selected: <span className="font-semibold text-white">{selectedCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
applyFieldChanges();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [applyFieldChanges, disabled, selectedCount]);
|
||||||
|
|
||||||
return (
|
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">
|
{/* Tiny inline bar */}
|
||||||
|
<div className="mb-3 flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
||||||
<div className="text-sm opacity-80">
|
<div className="text-sm opacity-80">
|
||||||
Selected: <span className="font-semibold">{selectedCount}</span>
|
Selected: <span className="font-semibold">{selectedCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-zinc-800" />
|
<button
|
||||||
|
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||||
{/* Platform */}
|
disabled={loading}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
onClick={() => setOpen(true)}
|
||||||
<select
|
>
|
||||||
value={bulkPlatformKey}
|
Bulk actions
|
||||||
onChange={(e) => setBulkPlatformKey(e.target.value)}
|
</button>
|
||||||
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>
|
||||||
</div>
|
|
||||||
|
{/* Drawer: all bulk controls */}
|
||||||
|
<RightDrawer
|
||||||
|
open={open}
|
||||||
|
onClose={setOpen}
|
||||||
|
title="Bulk actions"
|
||||||
|
widthClassName="max-w-lg"
|
||||||
|
footer={footer}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Set fields */}
|
||||||
|
<section className="rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-white/60">
|
||||||
|
Set fields
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{/* Platform */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-xs text-white/60">Platform</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={bulkPlatformKey}
|
||||||
|
onChange={(e) => setBulkPlatformKey(e.target.value)}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— No change —</option>
|
||||||
|
{platformOptions.map((p) => (
|
||||||
|
<option key={p.key} value={p.key}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bulkPlatformLock}
|
||||||
|
onChange={(e) => setBulkPlatformLock(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Lock
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-xs text-white/60">Role</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={bulkRole}
|
||||||
|
onChange={(e) => setBulkRole(e.target.value)}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— No change —</option>
|
||||||
|
{roleOptions.filter(Boolean).map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bulkRoleLock}
|
||||||
|
onChange={(e) => setBulkRoleLock(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Lock
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Caliber */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-xs text-white/60">Caliber</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={bulkCaliber}
|
||||||
|
onChange={(e) => setBulkCaliber(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— No change —</option>
|
||||||
|
{caliberOptions
|
||||||
|
.filter((c) => Boolean(c && c.isActive && c.key))
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id ?? c.key} value={c.key}>
|
||||||
|
{c.label || c.key}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={bulkCaliberGroup}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bulkCaliberLock}
|
||||||
|
onChange={(e) => setBulkCaliberLock(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Lock caliber
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bulkForceCaliber}
|
||||||
|
onChange={(e) => setBulkForceCaliber(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Force update locked
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-white/40">
|
||||||
|
Tip: stage changes above, then use <span className="text-white/70">Apply changes</span>.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<section className="rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-white/60">
|
||||||
|
Quick actions
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
|
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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Danger zone */}
|
||||||
|
<section className="rounded-lg border border-red-900/40 bg-red-950/20 p-4">
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-red-200">
|
||||||
|
Danger zone
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-red-600/80 px-3 py-2 text-sm font-semibold hover:bg-red-600 disabled:opacity-50"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() =>
|
||||||
|
runBulk({
|
||||||
|
visibility: "HIDDEN",
|
||||||
|
builderEligible: false,
|
||||||
|
adminLocked: true,
|
||||||
|
adminNote: "Hidden + removed from builder (bulk)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Hides products, removes from builder, and locks editing"
|
||||||
|
>
|
||||||
|
Hide + Remove + Lock
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</RightDrawer>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<React.SetStateAction<AdminFilters>>;
|
||||||
|
platformOptions: PlatformDto[];
|
||||||
|
roleOptions: string[];
|
||||||
|
caliberOptions: CaliberDto[];
|
||||||
|
loading: boolean;
|
||||||
|
onApply: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
selectedCount: number;
|
||||||
|
|
||||||
|
// column toggles
|
||||||
|
visibleCols: VisibleCols;
|
||||||
|
setVisibleCols: React.Dispatch<React.SetStateAction<VisibleCols>>;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const activeCount = useMemo(() => countActiveFilters(draft), [draft]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Top toolbar: stays small even on 14" */}
|
||||||
|
<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-[280px] 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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Filters{activeCount ? ` (${activeCount})` : ""}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||||
|
disabled={selectedCount === 0 || loading}
|
||||||
|
title={
|
||||||
|
selectedCount === 0
|
||||||
|
? "Select rows to enable bulk actions"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
Bulk ({selectedCount})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drawer: all the heavy controls */}
|
||||||
|
<RightDrawer
|
||||||
|
open={open}
|
||||||
|
onClose={setOpen}
|
||||||
|
title="Filters & Actions"
|
||||||
|
widthClassName="max-w-lg"
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||||
|
onClick={onClear}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="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();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="text-xs uppercase tracking-wide text-white/50">
|
||||||
|
Filters
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<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.label}
|
||||||
|
</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 && !!c.key)
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id ?? c.key} value={c.key}>
|
||||||
|
{c.label || c.key}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Columns */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-white/50">
|
||||||
|
Columns
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleCols({
|
||||||
|
brand: true,
|
||||||
|
platform: true,
|
||||||
|
role: true,
|
||||||
|
caliber: true,
|
||||||
|
import: true,
|
||||||
|
flags: true,
|
||||||
|
updated: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Full
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleCols({
|
||||||
|
brand: true,
|
||||||
|
platform: true,
|
||||||
|
role: true,
|
||||||
|
caliber: true,
|
||||||
|
import: true,
|
||||||
|
flags: false,
|
||||||
|
updated: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Compact
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
|
||||||
|
onClick={() =>
|
||||||
|
setVisibleCols({
|
||||||
|
brand: false,
|
||||||
|
platform: true,
|
||||||
|
role: true,
|
||||||
|
caliber: true,
|
||||||
|
import: true,
|
||||||
|
flags: true,
|
||||||
|
updated: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.brand}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({ ...c, brand: !c.brand }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Brand
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.platform}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({
|
||||||
|
...c,
|
||||||
|
platform: !c.platform,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Platform
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.role}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({ ...c, role: !c.role }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.caliber}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({ ...c, caliber: !c.caliber }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Caliber
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.import}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({ ...c, import: !c.import }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Import
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.flags}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({ ...c, flags: !c.flags }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Flags
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visibleCols.updated}
|
||||||
|
onChange={() =>
|
||||||
|
setVisibleCols((c) => ({ ...c, updated: !c.updated }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Updated
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-white/40">
|
||||||
|
Product + checkbox are always shown.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk actions placeholder */}
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-white/50">
|
||||||
|
Bulk actions
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-md border border-zinc-800 bg-black/30 p-3 text-sm text-white/70">
|
||||||
|
{selectedCount === 0
|
||||||
|
? "Select rows to enable bulk actions."
|
||||||
|
: `Ready for bulk actions on ${selectedCount} selected item(s).`}
|
||||||
|
<div className="mt-2 text-xs text-white/40">
|
||||||
|
(Hook your actual bulk controls here next.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RightDrawer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Transition show={open} as={Fragment}>
|
||||||
|
<Dialog onClose={onClose} className="relative z-50">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/60" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-hidden">
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="transform transition ease-in-out duration-300"
|
||||||
|
enterFrom="translate-x-full"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="transform transition ease-in-out duration-200"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="translate-x-full"
|
||||||
|
>
|
||||||
|
<DialogPanel
|
||||||
|
className={`pointer-events-auto w-screen ${widthClassName} bg-zinc-950 shadow-xl ring-1 ring-white/10`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-start justify-between border-b border-white/10 px-4 py-4">
|
||||||
|
<DialogTitle className="text-base font-semibold text-white">
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClose(false)}
|
||||||
|
className="rounded-md p-2 text-white/60 hover:text-white hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close panel</span>
|
||||||
|
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<div className="border-t border-white/10 px-4 py-3">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import type {
|
|||||||
AdminProductRow,
|
AdminProductRow,
|
||||||
PageResponse,
|
PageResponse,
|
||||||
PlatformDto,
|
PlatformDto,
|
||||||
|
CaliberDto,
|
||||||
} from "../_lib/types";
|
} from "../_lib/types";
|
||||||
|
|
||||||
|
|
||||||
import { initialFilters } from "../_lib/types";
|
import { initialFilters } from "../_lib/types";
|
||||||
import {
|
import {
|
||||||
bulkUpdate,
|
bulkUpdate,
|
||||||
@@ -16,11 +19,11 @@ import {
|
|||||||
fetchPlatforms,
|
fetchPlatforms,
|
||||||
} from "../_lib/api";
|
} from "../_lib/api";
|
||||||
|
|
||||||
import FilterBar from "./filterBar";
|
|
||||||
import FilterChips from "./filterChips";
|
import FilterChips from "./filterChips";
|
||||||
import ProductsTable from "./productsTable";
|
import ProductsTable from "./productsTable";
|
||||||
import PaginationBar from "./paginationBar";
|
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() {
|
export default function AdminProductsClient() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -37,8 +40,19 @@ export default function AdminProductsClient() {
|
|||||||
const [applied, setApplied] = useState<AdminFilters>(initialFilters);
|
const [applied, setApplied] = useState<AdminFilters>(initialFilters);
|
||||||
|
|
||||||
const [roleOptions, setRoleOptions] = useState<string[]>([""]);
|
const [roleOptions, setRoleOptions] = useState<string[]>([""]);
|
||||||
const [caliberOptions, setCaliberOptions] = useState<string[]>([""]);
|
const [caliberOptions, setCaliberOptions] = useState<CaliberDto[]>([]);
|
||||||
|
// ------------------------------
|
||||||
|
// Column Collasping
|
||||||
|
// ------------------------------
|
||||||
|
const [visibleCols, setVisibleCols] = useState({
|
||||||
|
brand: true,
|
||||||
|
platform: true,
|
||||||
|
role: true,
|
||||||
|
caliber: true,
|
||||||
|
import: true,
|
||||||
|
flags: true,
|
||||||
|
updated: true,
|
||||||
|
});
|
||||||
// ------------------------------
|
// ------------------------------
|
||||||
// Bulk action state
|
// Bulk action state
|
||||||
// ------------------------------
|
// ------------------------------
|
||||||
@@ -69,8 +83,18 @@ export default function AdminProductsClient() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetchAdminProducts(queryParams);
|
const res: any = await fetchAdminProducts(queryParams);
|
||||||
setData(res);
|
|
||||||
|
// 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());
|
setSelected(new Set());
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setErr(e?.message ?? "Failed to load.");
|
setErr(e?.message ?? "Failed to load.");
|
||||||
@@ -98,10 +122,10 @@ export default function AdminProductsClient() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const cals = await fetchAdminCalibers({});
|
const cals = await fetchAdminCalibers({ activeOnly: true });
|
||||||
setCaliberOptions(["", ...cals]);
|
setCaliberOptions(Array.isArray(cals) ? cals : []);
|
||||||
} catch {
|
} catch {
|
||||||
setCaliberOptions([""]);
|
setCaliberOptions([]);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -196,9 +220,11 @@ export default function AdminProductsClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-4 py-6">
|
// Prevent page-level horizontal scroll; allow table-only horizontal scroll.
|
||||||
|
<div className="w-full max-w-full overflow-x-hidden">
|
||||||
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-start justify-between gap-4">
|
<div className="mb-4 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-2xl font-semibold">Admin · Products</h1>
|
<h1 className="text-2xl font-semibold">Admin · Products</h1>
|
||||||
<p className="text-sm opacity-70">
|
<p className="text-sm opacity-70">
|
||||||
Filter, select, and apply bulk actions. This is where bad imports
|
Filter, select, and apply bulk actions. This is where bad imports
|
||||||
@@ -207,7 +233,7 @@ export default function AdminProductsClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
className="shrink-0 rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||||
onClick={() => refresh()}
|
onClick={() => refresh()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -215,72 +241,90 @@ export default function AdminProductsClient() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
{/* OPTIONAL IMPROVEMENT:
|
||||||
draft={draft}
|
Sticky controls area so table scroll doesn't push search/actions away.
|
||||||
setDraft={setDraft}
|
If you hate sticky, remove the wrapper div and keep the inner content. */}
|
||||||
platformOptions={platformOptions}
|
<div className="sticky top-0 z-20 -mx-2 px-2 pb-3 pt-2 bg-black/70 backdrop-blur border-b border-zinc-900">
|
||||||
roleOptions={roleOptions}
|
<ProductsToolbar
|
||||||
caliberOptions={caliberOptions}
|
draft={draft}
|
||||||
loading={loading}
|
setDraft={setDraft}
|
||||||
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}
|
platformOptions={platformOptions}
|
||||||
roleOptions={roleOptions}
|
roleOptions={roleOptions}
|
||||||
caliberOptions={caliberOptions}
|
caliberOptions={caliberOptions}
|
||||||
bulkPlatformKey={bulkPlatformKey}
|
loading={loading}
|
||||||
setBulkPlatformKey={setBulkPlatformKey}
|
onApply={applyFilters}
|
||||||
bulkPlatformLock={bulkPlatformLock}
|
onClear={clearFilters}
|
||||||
setBulkPlatformLock={setBulkPlatformLock}
|
selectedCount={selected.size}
|
||||||
bulkRole={bulkRole}
|
visibleCols={visibleCols}
|
||||||
setBulkRole={setBulkRole}
|
setVisibleCols={setVisibleCols}
|
||||||
bulkRoleLock={bulkRoleLock}
|
|
||||||
setBulkRoleLock={setBulkRoleLock}
|
|
||||||
bulkCaliber={bulkCaliber}
|
|
||||||
setBulkCaliber={setBulkCaliber}
|
|
||||||
bulkCaliberGroup={bulkCaliberGroup}
|
|
||||||
setBulkCaliberGroup={setBulkCaliberGroup}
|
|
||||||
bulkCaliberLock={bulkCaliberLock}
|
|
||||||
setBulkCaliberLock={setBulkCaliberLock}
|
|
||||||
bulkForceCaliber={bulkForceCaliber}
|
|
||||||
setBulkForceCaliber={setBulkForceCaliber}
|
|
||||||
runBulk={runBulk}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{err && (
|
{/* Chips stay visible too */}
|
||||||
<div className="mb-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
|
<FilterChips chips={chips} onClearOne={(k) => void clearOne(k)} />
|
||||||
{err}
|
|
||||||
|
{/* Bulk actions */}
|
||||||
|
{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="mt-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table + pagination area
|
||||||
|
min-w-0 is crucial to stop flex children from forcing horizontal overflow */}
|
||||||
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 pt-3">
|
||||||
|
{/* 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. */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<ProductsTable
|
||||||
|
rows={content}
|
||||||
|
loading={loading}
|
||||||
|
selected={selected}
|
||||||
|
onToggleAll={toggleAllOnPage}
|
||||||
|
onToggleOne={toggleOne}
|
||||||
|
allOnPageSelected={allOnPageSelected}
|
||||||
|
visibleCols={visibleCols}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<ProductsTable
|
<PaginationBar
|
||||||
rows={content}
|
page={page}
|
||||||
loading={loading}
|
setPage={setPage}
|
||||||
selected={selected}
|
size={size}
|
||||||
onToggleAll={toggleAllOnPage}
|
setSize={setSize}
|
||||||
onToggleOne={toggleOne}
|
loading={loading}
|
||||||
allOnPageSelected={allOnPageSelected}
|
totalPages={(data as any)?.totalPages ?? (data as any)?.page?.totalPages ?? 1}
|
||||||
/>
|
totalElements={(data as any)?.totalElements ?? (data as any)?.page?.totalElements ?? 0}
|
||||||
|
number={(data as any)?.number ?? (data as any)?.page?.number ?? page}
|
||||||
<PaginationBar
|
/>
|
||||||
page={page}
|
</div>
|
||||||
setPage={setPage}
|
|
||||||
size={size}
|
|
||||||
setSize={setSize}
|
|
||||||
loading={loading}
|
|
||||||
totalPages={data?.totalPages ?? 1}
|
|
||||||
totalElements={data?.totalElements ?? 0}
|
|
||||||
number={data?.number ?? 0}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import type { AdminProductRow } from "../_lib/types";
|
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({
|
export default function ProductsTable({
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
@@ -7,6 +17,7 @@ export default function ProductsTable({
|
|||||||
onToggleAll,
|
onToggleAll,
|
||||||
onToggleOne,
|
onToggleOne,
|
||||||
allOnPageSelected,
|
allOnPageSelected,
|
||||||
|
visibleCols,
|
||||||
}: {
|
}: {
|
||||||
rows: AdminProductRow[];
|
rows: AdminProductRow[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -14,34 +25,52 @@ export default function ProductsTable({
|
|||||||
onToggleAll: () => void;
|
onToggleAll: () => void;
|
||||||
onToggleOne: (id: number) => void;
|
onToggleOne: (id: number) => void;
|
||||||
allOnPageSelected: boolean;
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto rounded-md border border-zinc-800">
|
<div className="overflow-x-auto max-w-full min-w-0 rounded-md border border-zinc-800">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full min-w-[1100px] text-sm">
|
||||||
<thead className="bg-zinc-950">
|
<thead className="bg-zinc-950">
|
||||||
<tr className="text-left">
|
<tr className="text-left">
|
||||||
<th className="w-10 px-3 py-2">
|
{/* Sticky checkbox col */}
|
||||||
|
<th className="w-10 px-3 py-2 sticky left-0 z-30 bg-zinc-950">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allOnPageSelected}
|
checked={allOnPageSelected}
|
||||||
onChange={onToggleAll}
|
onChange={onToggleAll}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2">Product</th>
|
|
||||||
<th className="px-3 py-2">Brand</th>
|
{/* Sticky product col */}
|
||||||
<th className="px-3 py-2">Platform</th>
|
<th className="px-3 py-2 sticky left-10 z-20 bg-zinc-950 shadow-[1px_0_0_0_rgba(255,255,255,0.08)] w-[480px] max-w-[480px]">
|
||||||
<th className="px-3 py-2">Role</th>
|
Product
|
||||||
<th className="px-3 py-2">Caliber</th>
|
</th>
|
||||||
<th className="px-3 py-2">Import</th>
|
|
||||||
<th className="px-3 py-2">Flags</th>
|
{visibleCols.brand && <th className="px-3 py-2">Brand</th>}
|
||||||
<th className="px-3 py-2">Updated</th>
|
{visibleCols.platform && <th className="px-3 py-2">Platform</th>}
|
||||||
|
{visibleCols.role && <th className="px-3 py-2">Role</th>}
|
||||||
|
{visibleCols.caliber && <th className="px-3 py-2">Caliber</th>}
|
||||||
|
{visibleCols.import && <th className="px-3 py-2">Import</th>}
|
||||||
|
{visibleCols.flags && <th className="px-3 py-2">Flags</th>}
|
||||||
|
{visibleCols.updated && <th className="px-3 py-2">Updated</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading && (
|
{loading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
|
<td colSpan={colSpan} className="px-3 py-6 text-center opacity-70">
|
||||||
Loading…
|
Loading…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -49,7 +78,7 @@ export default function ProductsTable({
|
|||||||
|
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-3 py-6 text-center opacity-70">
|
<td colSpan={colSpan} className="px-3 py-6 text-center opacity-70">
|
||||||
No results.
|
No results.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -61,7 +90,8 @@ export default function ProductsTable({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
className="border-t border-zinc-900 hover:bg-zinc-950/40"
|
className="border-t border-zinc-900 hover:bg-zinc-950/40"
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2">
|
{/* Sticky checkbox */}
|
||||||
|
<td className="px-3 py-2 sticky left-0 z-30 bg-black">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(row.id)}
|
checked={selected.has(row.id)}
|
||||||
@@ -69,7 +99,8 @@ export default function ProductsTable({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2">
|
{/* Sticky product */}
|
||||||
|
<td className="px-3 py-2 sticky left-10 z-20 bg-black shadow-[1px_0_0_0_rgba(255,255,255,0.08)] w-[480px] max-w-[480px]">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{row.mainImageUrl ? (
|
{row.mainImageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@@ -83,11 +114,10 @@ export default function ProductsTable({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-medium">{row.name}</div>
|
<div className="font-medium whitespace-normal break-words leading-snug">
|
||||||
<div className="truncate text-xs opacity-60">
|
{row.name}
|
||||||
{/* {row.slug} · */}
|
|
||||||
#{row.id}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="truncate text-xs opacity-60">#{row.id}</div>
|
||||||
{row.adminNote ? (
|
{row.adminNote ? (
|
||||||
<div className="truncate text-xs opacity-60">
|
<div className="truncate text-xs opacity-60">
|
||||||
📝 {row.adminNote}
|
📝 {row.adminNote}
|
||||||
@@ -97,61 +127,78 @@ export default function ProductsTable({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-3 py-2">{row.brandName ?? "—"}</td>
|
{visibleCols.brand && (
|
||||||
<td className="px-3 py-2">{row.platform ?? "—"}</td>
|
<td className="px-3 py-2">{row.brandName ?? "—"}</td>
|
||||||
<td className="px-3 py-2">{row.partRole ?? "—"}</td>
|
)}
|
||||||
|
|
||||||
<td className="px-3 py-2">
|
{visibleCols.platform && (
|
||||||
<div className="flex flex-col">
|
<td className="px-3 py-2">{row.platform ?? "—"}</td>
|
||||||
<div className="text-sm">
|
)}
|
||||||
{row.caliber ?? "—"}
|
|
||||||
{row.caliberLocked ? (
|
{visibleCols.role && (
|
||||||
<span className="ml-2 rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] opacity-80">
|
<td className="px-3 py-2">{row.partRole ?? "—"}</td>
|
||||||
LOCK
|
)}
|
||||||
|
|
||||||
|
{visibleCols.caliber && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleCols.import && (
|
||||||
|
<td className="px-3 py-2">{row.importStatus ?? "—"}</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleCols.flags && (
|
||||||
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{row.caliberGroup ? (
|
</td>
|
||||||
<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">
|
{visibleCols.updated && (
|
||||||
<div className="flex flex-wrap gap-1 text-xs">
|
<td className="px-3 py-2 text-xs opacity-70">
|
||||||
{row.visibility === "HIDDEN" ? (
|
{row.updatedAt ?? "—"}
|
||||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
</td>
|
||||||
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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
ProductVisibility,
|
ProductVisibility,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
import type { CaliberDto } from "./types";
|
||||||
|
|
||||||
|
|
||||||
async function authedFetch(url: string, init?: RequestInit) {
|
async function authedFetch(url: string, init?: RequestInit) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -30,11 +33,15 @@ export async function fetchAdminRoles(params: Record<string, any>) {
|
|||||||
return (await res.json()) as string[];
|
return (await res.json()) as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminCalibers(params: Record<string, any>) {
|
// Facet/filtering can still use distinct values seen on products (optional)
|
||||||
|
export async function fetchProductCaliberFacet(params: Record<string, any>) {
|
||||||
const res = await authedFetch(
|
const res = await authedFetch(
|
||||||
`${API_BASE}/api/v1/admin/products/calibers?${qs(params)}`
|
`${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[];
|
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()}`);
|
if (!res.ok) throw new Error(`Failed to load platforms (${res.status}): ${await res.text()}`);
|
||||||
const all = (await res.json()) as PlatformDto[];
|
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(
|
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()}`);
|
if (!res.ok) throw new Error(`Bulk update failed (${res.status}): ${await res.text()}`);
|
||||||
|
|
||||||
return (await res.json()) as { updatedCount: number; skippedLockedCount: number };
|
return (await res.json()) as { updatedCount: number; skippedLockedCount: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function fetchAdminCalibers(params: Record<string, any>) {
|
||||||
|
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[];
|
||||||
}
|
}
|
||||||
@@ -58,4 +58,13 @@ export const initialFilters: AdminFilters = {
|
|||||||
status: "",
|
status: "",
|
||||||
builderEligible: "",
|
builderEligible: "",
|
||||||
adminLocked: "",
|
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;
|
||||||
};
|
};
|
||||||
@@ -74,12 +74,12 @@ export async function updatePlatform(
|
|||||||
dto: UpdatePlatformDto
|
dto: UpdatePlatformDto
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
|
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
|
||||||
method: "PUT",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...tokenHeaders,
|
...tokenHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(dto),
|
body: JSON.stringify({ isActive: dto.isActive }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user