refactor(wholesale): split 2391-line WholesaleClient into focused modules

The Wholesale Portal admin client was the largest single file in the app
(2391 lines) and mixed 11 distinct UI concerns in one module.

Split into focused files under src/components/wholesale/admin/:

  types.ts                       shared types (MsgFn, PendingRegistration, ...)
  WholesaleIcon.tsx              header SVG
  WholesaleLoadingSkeleton.tsx   loading state
  StatusBadge.tsx                order status pill
  AddRecipientForm.tsx           notification recipient input
  PriceSheetModal.tsx            bulk price-sheet send confirmation
  CustomerPricingPanel.tsx       per-customer pricing overrides
  WebhookSettingsSection.tsx     outbound webhook config + test dispatch
  DashboardTab.tsx               stat cards + recent orders + webhook feed
  ProductsTab.tsx                wholesale product CRUD
  CustomersTab.tsx               customers + registrations + pricing
  OrdersTab.tsx                  orders + bulk ops + deposit modals
  SettingsTab.tsx                portal settings + webhook + recipients

WholesaleClient.tsx is now a 189-line shell that owns data loading and
tab routing. No behavior changes; the original logic was preserved
verbatim. Mechanical extraction only.

Also fixed an existing import bug where getPendingWholesaleRegistrations
was being imported from @/actions/wholesale instead of
@/actions/wholesale-register (typecheck caught it).

Verified: typecheck clean for new code (pre-existing dahlia/fetch mock
errors unrelated). Build compiles through to the same pre-existing
billing type error. Zero new lint warnings introduced.

Refactor-progress tracked at /tmp/refactor-routecomm.md.
This commit is contained in:
Nora
2026-06-25 21:37:21 -06:00
parent 95eab42f4b
commit 880c52227a
14 changed files with 2317 additions and 2231 deletions
@@ -0,0 +1,264 @@
import { useEffect, useState } from "react";
import { AdminButton, AdminBadge } from "@/components/admin/design-system";
import {
type WholesaleProduct,
deleteWholesaleProduct,
saveWholesaleProduct,
} from "@/actions/wholesale";
import type { MsgFn } from "./types";
interface ProductsTabProps {
products: WholesaleProduct[];
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
}
// Wholesale product catalog management. Create/edit/delete products and their price tiers.
export default function ProductsTab({ products, brandId, onMsg, onRefresh }: ProductsTabProps) {
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<WholesaleProduct | null>(null);
const [form, setForm] = useState({
name: "", description: "", unitType: "each", availability: "available",
qtyAvailable: 0, priceTiers: "", hpSku: "", hpItemId: "",
handlingInstructions: "", storageWarning: "", productLabel: "",
});
const [saving, setSaving] = useState(false);
const [openProductActions, setOpenProductActions] = useState<string | null>(null);
const [deletingProduct, setDeletingProduct] = useState<string | null>(null);
// Close product actions dropdown when clicking outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (!(e.target as Element).closest(".product-actions-cell")) {
setOpenProductActions(null);
}
}
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, []);
function toggleProductActions(productId: string, e: React.MouseEvent) {
e.stopPropagation();
setOpenProductActions(prev => prev === productId ? null : productId);
}
async function handleDeleteProduct(productId: string) {
if (!confirm("Delete this product? Products attached to order items cannot be deleted. Set availability to unavailable instead.")) return;
setDeletingProduct(productId);
const result = await deleteWholesaleProduct(productId);
setDeletingProduct(null);
if (result.success) {
onMsg("success", "Product deleted.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to delete product.");
}
}
async function handleSave() {
setSaving(true);
let tiers: Array<{ min_qty: number; max_qty: number; price: number }> = [];
try { tiers = JSON.parse(form.priceTiers || "[]"); } catch {}
const result = await saveWholesaleProduct({
brandId,
id: editing?.id,
name: form.name,
description: form.description || undefined,
unitType: form.unitType,
availability: form.availability,
qtyAvailable: form.qtyAvailable,
priceTiers: tiers,
hpSku: form.hpSku || undefined,
hpItemId: form.hpItemId || undefined,
handlingInstructions: form.handlingInstructions || undefined,
storageWarning: form.storageWarning || undefined,
productLabel: form.productLabel || undefined,
});
setSaving(false);
if (result.success) {
onMsg("success", editing ? "Product updated." : "Product created.");
setShowForm(false);
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save.");
}
}
function openNew() {
setEditing(null);
setForm({ name: "", description: "", unitType: "each", availability: "available", qtyAvailable: 0, priceTiers: "", hpSku: "", hpItemId: "", handlingInstructions: "", storageWarning: "", productLabel: "" });
setShowForm(true);
}
function openEdit(p: WholesaleProduct) {
setEditing(p);
setForm({
name: p.name,
description: p.description ?? "",
unitType: p.unit_type,
availability: p.availability,
qtyAvailable: Number(p.qty_available),
priceTiers: JSON.stringify(p.price_tiers),
hpSku: p.hp_sku ?? "",
hpItemId: p.hp_item_id ?? "",
handlingInstructions: p.handling_instructions ?? "",
storageWarning: p.storage_warning ?? "",
productLabel: p.product_label ?? "",
});
setShowForm(true);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Products ({products.length})</h2>
<AdminButton variant="primary" size="sm" onClick={openNew}>
+ Add Product
</AdminButton>
</div>
{showForm && (
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">{editing ? "Edit Product" : "New Product"}</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label htmlFor="ws-prod-name" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Product Name</label>
<input id="ws-prod-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label htmlFor="ws-prod-desc" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Description</label>
<textarea id="ws-prod-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" rows={2} />
</div>
<div>
<label htmlFor="ws-prod-unit" className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Unit Type</label>
<select id="ws-prod-unit" value={form.unitType} onChange={e => setForm(f => ({ ...f, unitType: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
{["each", "48-count box", "bag", "pallet", "bin", "load", "custom"].map(u => (
<option key={u} value={u}>{u}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Availability</label>
<select value={form.availability} onChange={e => setForm(f => ({ ...f, availability: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]">
<option value="available">Available</option>
<option value="unavailable">Unavailable</option>
<option value="coming_soon">Coming Soon</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Qty Available</label>
<input type="number" value={form.qtyAvailable} onChange={e => setForm(f => ({ ...f, qtyAvailable: Number(e.target.value) }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">HP SKU</label>
<input value={form.hpSku} onChange={e => setForm(f => ({ ...f, hpSku: e.target.value }))}
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm outline-none focus:border-[var(--admin-accent)]" />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">
Price Tiers (JSON array of {"{\"min_qty\":1,\"max_qty\":24,\"price\":20}"})
</label>
<input value={form.priceTiers} onChange={e => setForm(f => ({ ...f, priceTiers: e.target.value }))}
placeholder='[{"min_qty":1,"max_qty":24,"price":20},{"min_qty":25,"max_qty":0,"price":18}]'
className="w-full rounded-xl border border-[var(--admin-border)] px-3 py-2 text-sm font-mono outline-none focus:border-[var(--admin-accent)]" />
</div>
</div>
<div className="mt-4 flex gap-3">
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
{saving ? "Saving..." : "Save Product"}
</AdminButton>
<AdminButton onClick={() => setShowForm(false)} variant="secondary">
Cancel
</AdminButton>
</div>
</div>
)}
<div className="rounded-2xl bg-white border border-[var(--admin-border)] shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--admin-bg-subtle)] text-[var(--admin-text-muted)]">
<tr>
<th className="px-5 py-3 font-semibold text-left">Product</th>
<th className="px-5 py-3 font-semibold text-left">Unit</th>
<th className="px-5 py-3 font-semibold text-left">Availability</th>
<th className="px-5 py-3 font-semibold text-left">In Stock</th>
<th className="px-5 py-3 font-semibold text-left">HP SKU</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--admin-border-light)]">
{products.length === 0 ? (
<tr>
<td colSpan={6} className="py-12 text-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
</div>
<p className="text-sm font-semibold text-slate-600 mb-1">No wholesale products yet</p>
<p className="text-xs text-slate-400">Add products to your wholesale catalog to get started.</p>
</div>
</td>
</tr>
) : products.map(p => (
<tr key={p.id} className="hover:bg-[var(--admin-bg-subtle)]">
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{p.name}</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.unit_type}</td>
<td className="px-5 py-3">
<AdminBadge variant={p.availability === "available" ? "success" : p.availability === "coming_soon" ? "warning" : "default"}>
{p.availability.replace("_", " ")}
</AdminBadge>
</td>
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{p.qty_available}</td>
<td className="px-5 py-4 font-mono text-xs text-[var(--admin-text-muted)]">{p.hp_sku ?? "—"}</td>
<td className="px-5 py-4 product-actions-cell relative">
<div className="flex items-center gap-1.5">
<div className="relative">
<button
onClick={(e) => toggleProductActions(p.id, e)}
title="More actions"
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
{openProductActions === p.id && (
<div
className="absolute bottom-full right-0 mb-1 z-30 w-48 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
onClick={e => e.stopPropagation()}
>
<button
onClick={() => { setOpenProductActions(null); openEdit(p); }}
className="w-full text-left px-4 py-3 text-[var(--admin-accent)] hover:bg-[var(--admin-accent-light)] flex items-center gap-3 font-medium"
>
<svg className="w-4 h-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit Product
</button>
<div className="my-1 border-t border-[var(--admin-border)]" />
<button
onClick={() => { setOpenProductActions(null); handleDeleteProduct(p.id); }}
disabled={deletingProduct === p.id}
className="w-full text-left px-4 py-3 text-[var(--admin-danger)] hover:bg-[var(--admin-danger-light)] flex items-center gap-3 disabled:opacity-50"
>
<svg className="w-4 h-4 text-[var(--admin-danger)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{deletingProduct === p.id ? "Deleting..." : "Delete Product"}
</button>
</div>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}