From 6cf3ea7c94513a8079dd508a4cfce4356b9fe404 Mon Sep 17 00:00:00 2001 From: Sean Date: Sun, 7 Dec 2025 09:57:46 -0500 Subject: [PATCH] see /admin/mapping page. --- app/admin/import-status/page.tsx | 112 +++++++++++ app/admin/mapping/page.tsx | 323 +++++++++++++++++++++++++++++++ app/builder/page.tsx | 51 ++++- data/builderSlots.ts | 263 +++++++++++++++++++++++++ 4 files changed, 746 insertions(+), 3 deletions(-) create mode 100644 app/admin/import-status/page.tsx create mode 100644 app/admin/mapping/page.tsx create mode 100644 data/builderSlots.ts diff --git a/app/admin/import-status/page.tsx b/app/admin/import-status/page.tsx new file mode 100644 index 0000000..31e4a6c --- /dev/null +++ b/app/admin/import-status/page.tsx @@ -0,0 +1,112 @@ +"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; +}; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +export default function ImportStatusPage() { + const [summary, setSummary] = useState([]); + const [byMerchant, setByMerchant] = useState([]); + + 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`), + ]); + + setSummary(await summaryRes.json()); + setByMerchant(await byMerchantRes.json()); + } + + load(); + }, []); + + const total = summary.reduce((sum, row) => sum + row.count, 0); + + 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} +
+
+
+
+
+ ); +} \ 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