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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user