From 05b30d868228a7c8e8baf56aa6487f02c1cf2090 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 6 Dec 2025 09:16:37 -0500 Subject: [PATCH 1/4] fixing url issues with renaming --- app/builder/[categoryId]/page.tsx | 10 +++++----- app/builder/page.tsx | 14 +++++++------- app/builder/{build => summary}/page.tsx | 0 components/BuilderNav.tsx | 2 +- components/CategoryColumn.tsx | 4 ++-- components/TopNav.tsx | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) rename app/builder/{build => summary}/page.tsx (100%) diff --git a/app/builder/[categoryId]/page.tsx b/app/builder/[categoryId]/page.tsx index aeea750..a47ddb5 100644 --- a/app/builder/[categoryId]/page.tsx +++ b/app/builder/[categoryId]/page.tsx @@ -163,7 +163,7 @@ export default function CategoryPage() { ...prev, [categoryId]: partId, })); - router.push("/gunbuilder"); + router.push("/builder"); } }; @@ -309,7 +309,7 @@ export default function CategoryPage() { Category Not Found Return to Gunbuilder @@ -326,7 +326,7 @@ export default function CategoryPage() { {/* Header */}
← Back to The Armory @@ -670,7 +670,7 @@ export default function CategoryPage() {
View Details @@ -733,7 +733,7 @@ export default function CategoryPage() {
View Details diff --git a/app/builder/page.tsx b/app/builder/page.tsx index f070fb9..7107a21 100644 --- a/app/builder/page.tsx +++ b/app/builder/page.tsx @@ -175,7 +175,7 @@ export default function GunbuilderPage() { return updated; }); - router.replace("/gunbuilder", { scroll: false }); + router.replace("/builder", { scroll: false }); } } }, [searchParams, router]); @@ -223,7 +223,7 @@ export default function GunbuilderPage() { const payload = JSON.stringify(build); const encoded = window.btoa(payload); const origin = window.location?.origin ?? ""; - const url = `${origin}/gunbuilder/build?build=${encodeURIComponent( + const url = `${origin}/builder/build?build=${encodeURIComponent( encoded, )}`; setShareUrl(url); @@ -338,10 +338,10 @@ export default function GunbuilderPage() {

- Shadow Standard Co. + BATTL BUILDERS

- The Build Bench: Early Access + BattlBuilder Early Access

Explore components from trusted brands, choose one part per @@ -372,7 +372,7 @@ export default function GunbuilderPage() { {/* Primary actions now live under the total */}

) : hasParts ? ( { // If you want to pre-select something later, you can handle here. diff --git a/app/builder/build/page.tsx b/app/builder/summary/page.tsx similarity index 100% rename from app/builder/build/page.tsx rename to app/builder/summary/page.tsx diff --git a/components/BuilderNav.tsx b/components/BuilderNav.tsx index 5aecdcf..cbc41ef 100644 --- a/components/BuilderNav.tsx +++ b/components/BuilderNav.tsx @@ -24,7 +24,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) { const currentCategory = activeCategoryId ?? searchParams.get("category") ?? undefined; - const baseHref = "/gunbuilder"; + const baseHref = "/builder"; const renderDropdown = (label: string, items: Category[]) => { if (!items.length) return null; diff --git a/components/CategoryColumn.tsx b/components/CategoryColumn.tsx index 02a11d2..f556472 100644 --- a/components/CategoryColumn.tsx +++ b/components/CategoryColumn.tsx @@ -37,7 +37,7 @@ export function CategoryColumn({

No part selected

{parts.length >= 1 && ( View All ({parts.length}) diff --git a/components/TopNav.tsx b/components/TopNav.tsx index da1dab4..ccf6011 100644 --- a/components/TopNav.tsx +++ b/components/TopNav.tsx @@ -42,7 +42,7 @@ export function TopNav() { {/* Center: main nav (Builder / Admin) */}
+

+ Import Status Dashboard +

+

+ Quick sanity check on how clean (or not) your catalog is. +

+ + {/* Status cards */} +
+ {summary.map((row) => ( +
+
+ {row.status} +
+
+ {row.count} +
+
+ {total > 0 + ? `${((row.count / total) * 100).toFixed(1)}% of catalog` + : "—"} +
+
+ ))} +
+ + {/* Merchant breakdown */} +
+

+ By Merchant & Platform +

+
+ + + + + + + + + + + {byMerchant.map((row, i) => ( + + + + + + + ))} + +
MerchantPlatformStatusCount
+ {row.merchantName} + {row.platform}{row.status} + {row.count} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/app/admin/mapping/page.tsx b/app/admin/mapping/page.tsx new file mode 100644 index 0000000..98bf594 --- /dev/null +++ b/app/admin/mapping/page.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +type PendingBucket = { + merchantId: number; + merchantName: string; + rawCategoryKey: string; + mappedPartRole: string | null; + productCount: number; +}; + +const PART_ROLE_OPTIONS: string[] = [ + // LOWER – receivers + "LOWER_RECEIVER_STRIPPED", + "LOWER_RECEIVER_COMPLETE", + "LOWER_RECEIVER_COMPLETE_PISTOL", + "LOWER_RECEIVER_COMPLETE_RIFLE", + "LOWER_RECEIVER_80", + "LOWER_RECEIVER_BILLET", + "LOWER_RECEIVER_FORGED", + "LOWER_RECEIVER_POLYMER", + + // LOWER – internals & controls + "LOWER_PARTS_KIT", + "LOWER_PARTS_KIT_ENHANCED", + + "TRIGGER", + "TRIGGER_OTHER", + "TRIGGER_COMBAT", + "TRIGGER_MATCH", + "TRIGGER_DROP_IN", + "TRIGGER_SINGLE_STAGE", + "TRIGGER_TWO_STAGE", + "TRIGGER_GUARD", + "PISTOL_GRIP", + "PISTOL_GRIP_ERGO", + "PISTOL_GRIP_VERTICAL", + "SAFETY_SELECTOR", + "SAFETY_SELECTOR_AMBI", + "SAFETY_SELECTOR_45_DEG", + "MAG_RELEASE", + "TAKEDOWN_PINS", + "SPRINGS_PINS_MISC", + + // LOWER – buffer & stock + "BUFFER_KIT", + "BUFFER_TUBE", + "BUFFER_TUBE_MILSPEC", + "BUFFER_TUBE_COMMERCIAL", + "BUFFER_SPRING", + "BUFFER_WEIGHT", + "STOCK_ADJUSTABLE", + "STOCK_FIXED", + "STOCK_FOLDER", + "STOCK_PRECISION", + "BRACE_PISTOL", + + // UPPER – receivers & BCG + "UPPER_RECEIVER_STRIPPED", + "UPPER_RECEIVER_BILLET", + "UPPER_RECEIVER_MONOLITHIC", + "UPPER_RECEIVER_COMPLETE", + "UPPER_RECEIVER_COMPLETE_PISTOL", + "UPPER_RECEIVER_COMPLETE_RIFLE", + "BOLT_CARRIER_GROUP", + "BCG_COMPLETE", + "BCG_LIGHTWEIGHT", + "BCG_NICKEL_BORON", + + // UPPER – barrel & gas + "BARREL", + "BARREL_THREADED", + "BARREL_PENCIL", + "BARREL_MATCH", + "BARREL_HEAVY", + "BARREL_14_5_PINNED", + "GAS_BLOCK", + "GAS_BLOCK_ADJUSTABLE", + "GAS_BLOCK_LOW_PROFILE", + "GAS_TUBE_PISTOL", + "GAS_TUBE_CAR", + "GAS_TUBE_MID", + "GAS_TUBE_RIFLE", + + // UPPER – front end / muzzle + "HANDGUARD_MLOK", + "HANDGUARD_KEYMOD", + "HANDGUARD_QUAD", + "HANDGUARD_SLICK", + "MUZZLE_BRAKE", + "MUZZLE_COMPENSATOR", + + // 👇 generic / backup muzzle roles + "MUZZLE_DEVICE", + "MUZZLE_DEVICE_OTHER", + + "FLASH_HIDER", + "MUZZLE_DEVICE_QD_MOUNT", + "SUPPRESSOR_DIRECT_THREAD", + "SUPPRESSOR_QD", + "SUPPRESSOR_RIFLE", + "SUPPRESSOR_PISTOL", + "CHARGING_HANDLE", + "CHARGING_HANDLE_AMBI", + "CHARGING_HANDLE_GAS_BUSTER", + + // Sights / optics + "SIGHTS_BACKUP", + "SIGHTS_IRON_FIXED", + "SIGHTS_IRON_FLIP", + "SIGHTS_OFFSET", + "OPTIC_REDDOT", + "OPTIC_LPVO", + "OPTIC_HOLOGRAPHIC", + "OPTIC_PRISM", + "OPTIC_MAGNIFIER", + + // Accessories + "MAGAZINE", + "SLING", + "BIPOD", + "MOUNT_SCOPE", + "MOUNT_OFFSET", + "LIGHT_WEAPON", + "LASER_VISIBLE", + "LASER_IR", + "TOOL", + ]; + +export default function MappingAdminPage() { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [savingKey, setSavingKey] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + setLoading(true); + setError(null); + const res = await fetch( + `${API_BASE_URL}/api/admin/mapping/pending-buckets` + ); + if (!res.ok) { + throw new Error(`Failed to load pending buckets (${res.status})`); + } + const data: PendingBucket[] = await res.json(); + setRows(data); + } catch (e: any) { + console.error(e); + setError(e.message ?? "Failed to load pending buckets"); + } finally { + setLoading(false); + } + } + + load(); + }, []); + + const handleChangeRole = (idx: number, value: string) => { + setRows((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], mappedPartRole: value || null }; + return next; + }); + }; + + const handleSave = async (row: PendingBucket) => { + if (!row.mappedPartRole) return; + + const key = `${row.merchantId}-${row.rawCategoryKey}`; + try { + setSavingKey(key); + setError(null); + + const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + merchantId: row.merchantId, + rawCategoryKey: row.rawCategoryKey, + mappedPartRole: row.mappedPartRole, + }), + }); + + if (!res.ok) { + throw new Error(`Failed to save mapping (${res.status})`); + } + + // After save, remove this bucket from the list + setRows((prev) => + prev.filter( + (r) => + !( + r.merchantId === row.merchantId && + r.rawCategoryKey === row.rawCategoryKey + ) + ) + ); + } catch (e: any) { + console.error(e); + setError(e.message ?? "Failed to save mapping"); + } finally { + setSavingKey(null); + } + }; + + return ( +
+
+

+ Bulk Mapping – Pending Categories +

+

+ Bucketed by merchant + raw category. Map each bucket to a part role to + clean up the builder catalog. +

+ + {loading && ( +

Loading pending buckets…

+ )} + + {error && ( +

Error: {error}

+ )} + + {!loading && rows.length === 0 && !error && ( +

+ No pending mapping buckets 🎉 +

+ )} + + {!loading && rows.length > 0 && ( +
+
+

+ Pending Buckets +

+ + {rows.length} buckets • {rows.reduce((s, r) => s + r.productCount, 0)}{" "} + products + +
+ +
+ + + + + + + + + + + + {rows.map((row, idx) => { + const rowKey = `${row.merchantId}-${row.rawCategoryKey}`; + const isSaving = savingKey === rowKey; + + return ( + + + + + + + + ); + })} + +
+ Merchant + + Raw Category + + Products + + Part Role + + Action +
+ {row.merchantName} + + {row.rawCategoryKey} + {row.productCount} + + + +
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/builder/page.tsx b/app/builder/page.tsx index 7107a21..f5cd4c1 100644 --- a/app/builder/page.tsx +++ b/app/builder/page.tsx @@ -4,9 +4,10 @@ import { useMemo, useState, useEffect } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { CATEGORIES } from "@/data/gunbuilderParts"; +import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; import type { CategoryId, Part } from "@/types/gunbuilder"; import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings"; -import { Pencil, Link2 } from "lucide-react"; +import { Pencil, Link2, Square, CheckSquare } from "lucide-react"; type GunbuilderProductFromApi = { id: number; // backend numeric id @@ -205,6 +206,17 @@ export default function GunbuilderPage() { .filter(Boolean) as Part[]; }, [build, parts]); + const selectedByCategory: Record = useMemo( + () => + Object.fromEntries( + Object.entries(build).map(([categoryId, partId]) => [ + categoryId as CategoryId, + partId ? true : false, + ]), + ) as Record, + [build], + ); + const totalPrice = useMemo( () => selectedParts.reduce((sum, p) => sum + p.price, 0), [selectedParts], @@ -352,8 +364,9 @@ export default function GunbuilderPage() {
- {/* Build summary panel */} -
+ + {/* Build summary panel */} +
{/* Left: title + totals + primary actions */}

@@ -462,6 +475,38 @@ export default function GunbuilderPage() {

+ {/* Build status strip */} +
+

+ Build Status (Needs Refined) +

+
+ {BUILDER_SLOTS.map((slot) => { + const satisfied = isSlotSatisfied(slot, selectedByCategory); + + return ( +
+ {satisfied ? ( + + ) : ( + + )} + + {slot.label} + +
+ ); + })} +
+
+ {/* Layout */}

diff --git a/data/builderSlots.ts b/data/builderSlots.ts new file mode 100644 index 0000000..302dc30 --- /dev/null +++ b/data/builderSlots.ts @@ -0,0 +1,263 @@ +// data/builderSlots.ts + +/** + * Builder "slots" represent the major assemblies in a Battl Builder rifle: + * - Lower Receiver Assembly + * - Upper Receiver Assembly + * - Fire Control / Optics / Accessories, etc. + * + * Each slot can be satisfied by one or more "patterns" of categories. + * Example: + * Lower Assembly is satisfied by EITHER: + * - a single Complete Lower + * - OR a combination of stripped lower + parts + buffer + stock + grip + safety + * + * The frontend can use this file to: + * - show build progress for each slot + * - grey out / collapse sub-categories when a complete assembly is selected + * - validate that a build is "complete" before sharing/saving + */ + +export type SlotGroup = "LOWER" | "UPPER" | "SYSTEM" | "ACCESSORIES"; + +/** + * CategoryId should correspond to CATEGORIES[id] from data/gunbuilderParts.ts + * We keep it loose here to avoid circular type imports. + */ +export type CategoryId = string; + +export type BuilderSlotId = + | "lowerAssembly" + | "upperAssembly" + | "opticSystem" + | "sightSystem" + | "suppressorSystem" + | "magazineSystem" + | "accessoriesSupport"; + +export interface BuilderSlot { + id: BuilderSlotId; + label: string; + group: SlotGroup; + /** + * Human-facing description for tooltips / help text. + */ + description: string; + /** + * All category IDs that are visually associated with this slot. + * (Useful for layout / grouping in the UI.) + */ + categories: CategoryId[]; + /** + * Each inner array is a valid way to "satisfy" the slot. + * + * Example (lowerAssembly): + * [ + * ["completeLower"], // Pattern 1: single complete lower + * ["lower", "lowerParts", "trigger", "grip", "safety", "buffer", "stock"] // Pattern 2: custom lower + * ] + */ + satisfactionPatterns: CategoryId[][]; +} + +/** + * Core Battl Builder slots. + * + * These line up with your existing CATEGORIES in data/gunbuilderParts.ts: + * + * LOWER: + * - lower + * - completeLower + * - lowerParts + * - trigger + * - grip + * - safety + * - buffer + * - stock + * + * UPPER: + * - upper + * - completeUpper + * - bcg + * - barrel + * - gasBlock + * - gasTube + * - muzzleDevice + * - suppressor + * - handguard + * - chargingHandle + * - sights + * - optic + * + * ACCESSORIES: + * - magazine + * - weaponLight + * - foregrip + * - bipod + * - sling + * - railAccessory + * - tools + */ +export const BUILDER_SLOTS: BuilderSlot[] = [ + { + id: "lowerAssembly", + label: "Lower Receiver Assembly", + group: "LOWER", + description: + "The serialized foundation of the rifle: receiver, controls, fire control, stock, and buffer system.", + categories: [ + "lower", + "completeLower", + "lowerParts", + "trigger", + "grip", + "safety", + "buffer", + "stock", + ], + satisfactionPatterns: [ + // Pattern 1: single complete lower does it all + ["completeLower"], + // Pattern 2: fully custom lower built from individual parts + ["lower", "lowerParts", "trigger", "grip", "safety", "buffer", "stock"], + ], + }, + + { + id: "upperAssembly", + label: "Upper Receiver Assembly", + group: "UPPER", + description: + "The working end of the rifle: upper, barrel, gas system, handguard, BCG, charging handle, and muzzle device.", + categories: [ + "upper", + "completeUpper", + "bcg", + "barrel", + "gasBlock", + "gasTube", + "handguard", + "muzzleDevice", + "chargingHandle", + ], + satisfactionPatterns: [ + // Pattern 1: complete upper + ["completeUpper"], + // Pattern 2: custom upper from parts + [ + "upper", + "barrel", + "gasBlock", + "gasTube", + "handguard", + "bcg", + "chargingHandle", + "muzzleDevice", + ], + ], + }, + + { + id: "opticSystem", + label: "Optic System", + group: "SYSTEM", + description: + "Primary glass for the rifle: LPVOs, red dots, prisms, and magnifiers.", + categories: ["optic"], + satisfactionPatterns: [ + // For now: any optic fills the slot. + ["optic"], + ], + }, + + { + id: "sightSystem", + label: "Backup / Iron Sights", + group: "SYSTEM", + description: + "Backup irons or primary sights for no-glass builds and hard-use setups.", + categories: ["sights"], + satisfactionPatterns: [["sights"]], + }, + + { + id: "suppressorSystem", + label: "Muzzle / Suppressor", + group: "SYSTEM", + description: + "Dedicated suppressor slot for QD or direct-thread cans. (Muzzle device is handled in the upper assembly patterns.)", + categories: ["suppressor"], + satisfactionPatterns: [["suppressor"]], + }, + + { + id: "magazineSystem", + label: "Magazines", + group: "ACCESSORIES", + description: + "Feeding the beast: mags appropriate to the platform and caliber.", + categories: ["magazine"], + satisfactionPatterns: [["magazine"]], + }, + + { + id: "accessoriesSupport", + label: "Support Gear", + group: "ACCESSORIES", + description: + "Lights, lasers, foregrips, bipods, slings, rail widgets, and tools that keep the rifle honest.", + categories: [ + "weaponLight", + "foregrip", + "bipod", + "sling", + "railAccessory", + "tools", + ], + // Accessories are all optional; this slot is never strictly "required" + satisfactionPatterns: [ + // An empty pattern would technically always be satisfied, + // but we can treat this slot as "nice to have" in the UI instead. + ["weaponLight"], + ["foregrip"], + ["bipod"], + ["sling"], + ["railAccessory"], + ["tools"], + ], + }, +]; + +/** + * Helper: determine whether a slot is satisfied given currently selected parts. + * + * `selectedByCategory` is expected to be a map of categoryId → truthy if selected. + * The value can be the actual Part, or just `true` if you track it elsewhere. + */ +export function isSlotSatisfied( + slot: BuilderSlot, + selectedByCategory: Record +): boolean { + return slot.satisfactionPatterns.some((pattern) => + pattern.every((categoryId) => Boolean(selectedByCategory[categoryId])) + ); +} + +/** + * Optional helper: get the first pattern that is currently satisfied. + * Can be used to: + * - show which "path" the user took (complete vs custom) + * - conditionally collapse/expand sub-categories in the UI + */ +export function getSatisfiedPattern( + slot: BuilderSlot, + selectedByCategory: Record +): CategoryId[] | null { + for (const pattern of slot.satisfactionPatterns) { + const ok = pattern.every((categoryId) => + Boolean(selectedByCategory[categoryId]) + ); + if (ok) return pattern; + } + return null; +} \ No newline at end of file From 9e5b86967b01c8cb025d50bc696e248aed66af10 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 7 Dec 2025 16:18:28 -0500 Subject: [PATCH 4/4] new admin dashbioard and mapping page --- app/admin/import-status/page.tsx | 322 ++++++++++++++++++++++--------- app/admin/page.tsx | 221 +++++++++++++++------ 2 files changed, 401 insertions(+), 142 deletions(-) diff --git a/app/admin/import-status/page.tsx b/app/admin/import-status/page.tsx index 31e4a6c..c3d44ed 100644 --- a/app/admin/import-status/page.tsx +++ b/app/admin/import-status/page.tsx @@ -1,111 +1,259 @@ "use client"; import { useEffect, useState } from "react"; - -type ImportStatus = "RAW" | "PENDING_MAPPING" | "MAPPED"; - -type SummaryRow = { - status: ImportStatus; - count: number; -}; - -type ByMerchantRow = { - merchantName: string; - platform: string; - status: ImportStatus; - count: number; -}; +import Link from "next/link"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; +const formatCount = (value: number | null | undefined) => + (typeof value === "number" ? value : 0).toLocaleString(); + +type ImportSummary = { + totalProducts: number; + mappedProducts: number; + pendingProducts: number; +}; + +type RawByMerchantRow = { + merchantId: number; + merchantName: string; + platform: string; + status: string; + count: number; +}; + +type MerchantImportRow = { + merchantId: number; + merchantName: string; + totalProducts: number; + mappedProducts: number; + pendingProducts: number; + lastImportAt?: string | null; +}; + export default function ImportStatusPage() { - const [summary, setSummary] = useState([]); - const [byMerchant, setByMerchant] = useState([]); + const [summary, setSummary] = useState(null); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [importingId, setImportingId] = useState(null); useEffect(() => { async function load() { - const [summaryRes, byMerchantRes] = await Promise.all([ - fetch(`${API_BASE_URL}/api/admin/import-status/summary`), - fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), - ]); + try { + setLoading(true); + setError(null); - setSummary(await summaryRes.json()); - setByMerchant(await byMerchantRes.json()); + const [summaryRes, byMerchantRes] = await Promise.all([ + fetch(`${API_BASE_URL}/api/admin/import-status/summary`), + fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), + ]); + + if (!summaryRes.ok || !byMerchantRes.ok) { + throw new Error("Failed to load import status"); + } + + const summaryJson: ImportSummary = await summaryRes.json(); + const byMerchantRaw: RawByMerchantRow[] = await byMerchantRes.json(); + + // Aggregate per merchant using merchantId as the key + const merchantMap = new Map(); + + for (const row of byMerchantRaw) { + const id = row.merchantId; + const name = row.merchantName; + const status = row.status; + const count = row.count ?? 0; + + if (!merchantMap.has(id)) { + merchantMap.set(id, { + merchantId: id, + merchantName: name, + totalProducts: 0, + mappedProducts: 0, + pendingProducts: 0, + lastImportAt: null, // not provided by API yet + }); + } + + const agg = merchantMap.get(id)!; + agg.totalProducts += count; + + if (status === "MAPPED") { + agg.mappedProducts += count; + } else if (status === "PENDING_MAPPING") { + agg.pendingProducts += count; + } + } + + const byMerchantJson: MerchantImportRow[] = Array.from( + merchantMap.values() + ); + + setSummary(summaryJson); + setRows(byMerchantJson); + } catch (e: any) { + console.error(e); + setError(e.message ?? "Failed to load import status"); + } finally { + setLoading(false); + } } load(); }, []); - const total = summary.reduce((sum, row) => sum + row.count, 0); + async function handleReimport(merchantId: number) { + try { + setImportingId(merchantId); + const res = await fetch( + `${API_BASE_URL}/api/admin/merchants/${merchantId}/import`, + { + method: "POST", + } + ); + + if (!res.ok) { + throw new Error(`Re-import failed (${res.status})`); + } + + // Optionally re-load data here if you want the table to refresh automatically + } catch (e: any) { + console.error(e); + alert(e.message ?? "Failed to trigger import"); + } finally { + setImportingId(null); + } + } return (

-
-

- Import Status Dashboard -

-

- Quick sanity check on how clean (or not) your catalog is. -

- - {/* Status cards */} -
- {summary.map((row) => ( -
-
- {row.status} -
-
- {row.count} -
-
- {total > 0 - ? `${((row.count / total) * 100).toFixed(1)}% of catalog` - : "—"} -
-
- ))} -
- - {/* Merchant breakdown */} -
-

- By Merchant & Platform -

-
- - - - - - - - - - - {byMerchant.map((row, i) => ( - - - - - - - ))} - -
MerchantPlatformStatusCount
- {row.merchantName} - {row.platform}{row.status} - {row.count} -
+
+
+
+

+ Import Status +

+

+ Per-merchant feed health and mapping coverage. +

-
+ + ← Back to Admin Home + +
+ + {loading && ( +

Loading import status…

+ )} + + {error &&

Error: {error}

} + + {summary && !loading && !error && ( + <> + {/* Summary cards */} +
+
+

+ Total Products +

+

+ {formatCount(summary.totalProducts)} +

+
+
+

+ Mapped +

+

+ {formatCount(summary.mappedProducts)} +

+
+
+

+ Pending Mapping +

+

+ {formatCount(summary.pendingProducts)} +

+
+
+ +
+

+ By Merchant +

+ +
+ + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
MerchantTotalMappedPendingLast ImportActions
+ + {row.merchantName} + + + {formatCount(row.totalProducts)} + + {formatCount(row.mappedProducts)} + + {formatCount(row.pendingProducts)} + + {row.lastImportAt + ? new Date(row.lastImportAt).toLocaleString() + : "—"} + +
+ + Mapping + + + +
+
+
+
+ + )} ); diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 6fb7bee..44441f2 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,67 +1,178 @@ "use client"; +import { useEffect, useState } from "react"; import Link from "next/link"; -import { useAuth } from "@/context/AuthContext"; -export default function AdminHomePage() { - const { token } = useAuth(); +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - if (!token) { - return ( -
- You must be logged in to view this page. -
- ); - } +type AdminOverview = { + totalProducts: number; + mappedProducts: number; + unmappedProducts: number; + merchantCount: number; + categoryMappingCount: number; +}; - const cards = [ - { - title: "Canonical categories", - href: "admin/categories", - description: - "Manage the core builder categories and their group/grouping + sort order.", - }, - { - title: "Part role mappings", - href: "/admin/part-role-mappings", - description: - "Advanced: map logical builder part roles to canonical categories per platform.", - }, - { - title: "Merchant category mappings", - href: "/admin/merchant-category-mappings", - description: - "Map raw merchant feed categories to your canonical categories.", - }, - ]; +export default function AdminLandingPage() { + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + setLoading(true); + setError(null); + const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`); + if (!res.ok) { + throw new Error(`Failed to load dashboard (${res.status})`); + } + const data: AdminOverview = await res.json(); + setOverview(data); + } catch (e: any) { + console.error(e); + setError(e.message ?? "Failed to load admin dashboard"); + } finally { + setLoading(false); + } + } + + load(); + }, []); return ( -
-
-

- Admin -

-

- Internal tools to keep your builder data clean and consistent. -

-
- -
- {cards.map((card) => ( - -

- {card.title} -

-

- {card.description} +

+
+
+
+

+ Battl Builder – Admin +

+

+ Control room for feeds, mappings, and catalog health.

- - ))} +
+
+ + {loading && ( +

Loading dashboard…

+ )} + + {error && ( +

Error: {error}

+ )} + + {overview && !loading && !error && ( + <> + {/* Stats row */} +
+
+

+ Total Products +

+

+ {overview.totalProducts.toLocaleString()} +

+
+ +
+

+ Mapped Products +

+

+ {overview.mappedProducts.toLocaleString()} +

+
+ +
+

+ Unmapped Products +

+

+ {overview.unmappedProducts.toLocaleString()} +

+

+ Products still in PENDING_MAPPING. +

+
+ +
+

+ Merchants / Mappings +

+

+ {overview.merchantCount}{" "} + + merchants + +

+

+ {overview.categoryMappingCount.toLocaleString()} mappings +

+
+
+ + {/* Nav cards */} +
+

+ Admin Tools +

+
+ {/* Bulk Mapping */} + +

+ Bulk Category Mapping +

+

+ Bucket raw merchant categories and map them to part roles to + keep the catalog clean. +

+

+ {overview.unmappedProducts.toLocaleString()} products + pending mapping +

+ + + {/* Import Status */} + +

+ Import Status +

+

+ Inspect last feed runs, per-merchant health, and errors. +

+

+ View import summaries  → +

+ + + {/* Placeholder for future admin tools */} + +

+ Catalog Explorer +

+

+ (Future) Browse normalized products, offers, and compatibility. +

+

+ Coming soon +

+ +
+
+ + )}
-
+ ); } \ No newline at end of file