a metric shit ton. fixed a lot of part_role issues. removed almost all hardcoded categories and used db roles.

This commit is contained in:
2025-12-15 20:59:19 -05:00
parent 0f10ff4e09
commit 607939c468
16 changed files with 1538 additions and 1065 deletions
+102 -127
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -13,123 +13,58 @@ type PendingBucket = {
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",
/**
* Canonical part roles (kebab-case) — should match what the importer normalizes to.
* Keep this list tight + intentional so mappings don't create junk roles in the DB.
*/
const PART_ROLE_OPTIONS = [
// Assemblies / receivers
"upper-receiver",
"complete-upper",
"lower-receiver",
"complete-lower",
"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",
// Upper sub-parts
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
// 👇 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",
];
// Lower sub-parts
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
// Sights / optics
"sights",
"optic",
// Accessories / gear
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
] as const;
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
function toLabel(role: string) {
// "complete-upper" -> "Complete Upper"
return role
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]);
@@ -137,17 +72,33 @@ export default function MappingAdminPage() {
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const options = useMemo(
() =>
PART_ROLE_OPTIONS.map((value) => ({
value,
label: toLabel(value),
})),
[]
);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/pending-buckets`
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
{ headers: { Accept: "application/json" } }
);
if (!res.ok) {
throw new Error(`Failed to load pending buckets (${res.status})`);
const text = await res.text().catch(() => "");
throw new Error(
`Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}`
);
}
const data: PendingBucket[] = await res.json();
setRows(data);
} catch (e: any) {
@@ -162,9 +113,14 @@ export default function MappingAdminPage() {
}, []);
const handleChangeRole = (idx: number, value: string) => {
// Always store normalized kebab-case (defensive)
const normalized = value
? value.trim().toLowerCase().replace(/_/g, "-")
: "";
setRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: value || null };
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next;
});
};
@@ -172,6 +128,20 @@ export default function MappingAdminPage() {
const handleSave = async (row: PendingBucket) => {
if (!row.mappedPartRole) return;
const mapped = row.mappedPartRole
.trim()
.toLowerCase()
.replace(/_/g, "-");
// Basic guard: prevent saving random roles from stale data
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
if (!allowed.has(mapped)) {
setError(
`Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.`
);
return;
}
const key = `${row.merchantId}-${row.rawCategoryKey}`;
try {
setSavingKey(key);
@@ -183,12 +153,15 @@ export default function MappingAdminPage() {
body: JSON.stringify({
merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey,
mappedPartRole: row.mappedPartRole,
mappedPartRole: mapped,
}),
});
if (!res.ok) {
throw new Error(`Failed to save mapping (${res.status})`);
const text = await res.text().catch(() => "");
throw new Error(
`Failed to save mapping (${res.status})${text ? `: ${text}` : ""}`
);
}
// After save, remove this bucket from the list
@@ -241,8 +214,8 @@ export default function MappingAdminPage() {
Pending Buckets
</h2>
<span className="text-xs text-zinc-400">
{rows.length} buckets {rows.reduce((s, r) => s + r.productCount, 0)}{" "}
products
{rows.length} buckets {" "}
{rows.reduce((s, r) => s + r.productCount, 0)} products
</span>
</div>
@@ -283,7 +256,9 @@ export default function MappingAdminPage() {
<td className="px-2 py-1 text-zinc-300">
{row.rawCategoryKey}
</td>
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
<td className="px-2 py-1 text-zinc-200">
{row.productCount}
</td>
<td className="px-2 py-1 text-zinc-100">
<select
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
@@ -293,9 +268,9 @@ export default function MappingAdminPage() {
}
>
<option value="">Select part role</option>
{PART_ROLE_OPTIONS.map((pr) => (
<option key={pr} value={pr}>
{pr}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} ({opt.value})
</option>
))}
</select>