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,287 @@
import { useState } from "react";
import { AdminButton } from "@/components/admin/design-system";
import {
type WholesaleSettings,
type NotificationRecipient,
saveWholesaleSettings,
} from "@/actions/wholesale";
import AddRecipientForm from "./AddRecipientForm";
import WebhookSettingsSection from "./WebhookSettingsSection";
import type { MsgFn } from "./types";
interface SettingsTabProps {
settings: WholesaleSettings | null;
brandId: string;
onMsg: MsgFn;
onRefresh: () => void;
canManageSettings: boolean;
}
// Wholesale portal settings: approval flow, payments, pickup location, invoice
// details, Square sync toggle, team notification recipients, and outbound webhook.
export default function SettingsTab({ settings, brandId, onMsg, onRefresh, canManageSettings }: SettingsTabProps) {
// Hooks must be called unconditionally - always declare them first
const [form, setForm] = useState({
requireApproval: settings?.require_approval ?? true,
minOrderAmount: settings?.min_order_amount ?? "",
onlinePaymentEnabled: settings?.online_payment_enabled ?? false,
wholesaleEnabled: settings?.wholesale_enabled ?? true,
squareSyncEnabled: settings?.square_sync_enabled ?? false,
pickupLocation: settings?.pickup_location ?? "",
fobLocation: settings?.fob_location ?? "",
fromEmail: settings?.from_email ?? "",
invoiceBusinessName: settings?.invoice_business_name ?? "",
invoiceBusinessAddress: settings?.invoice_business_address ?? "",
invoiceBusinessPhone: settings?.invoice_business_phone ?? "",
invoiceBusinessEmail: settings?.invoice_business_email ?? "",
invoiceBusinessWebsite: settings?.invoice_business_website ?? "",
notificationRecipients: settings?.notification_recipients ?? [],
});
const [saving, setSaving] = useState(false);
// Permission check after hooks
if (!canManageSettings) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-slate-400">You do not have permission to manage settings.</p>
</div>
);
}
async function handleSave() {
setSaving(true);
const result = await saveWholesaleSettings({
brandId,
requireApproval: form.requireApproval,
minOrderAmount: form.minOrderAmount ? Number(form.minOrderAmount) : undefined,
onlinePaymentEnabled: form.onlinePaymentEnabled,
wholesaleEnabled: form.wholesaleEnabled,
squareSyncEnabled: form.squareSyncEnabled,
pickupLocation: form.pickupLocation,
fobLocation: form.fobLocation,
fromEmail: form.fromEmail,
invoiceBusinessName: form.invoiceBusinessName,
invoiceBusinessAddress: form.invoiceBusinessAddress,
invoiceBusinessPhone: form.invoiceBusinessPhone,
invoiceBusinessWebsite: form.invoiceBusinessWebsite,
notificationRecipients: form.notificationRecipients,
});
setSaving(false);
if (result.success) {
onMsg("success", "Settings saved.");
onRefresh();
} else {
onMsg("error", result.error ?? "Failed to save settings.");
}
}
// ── Notification Recipients helpers ────────────────────────────────────────
function addRecipient(email: string, name: string = "") {
setForm(f => ({
...f,
notificationRecipients: [
...f.notificationRecipients,
{ email, name, active: true },
],
}));
}
function removeRecipient(idx: number) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.filter((_: unknown, i: number) => i !== idx),
}));
}
function toggleRecipient(idx: number) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
i === idx ? { ...r, active: !r.active } : r
),
}));
}
function updateRecipientName(idx: number, name: string) {
setForm(f => ({
...f,
notificationRecipients: f.notificationRecipients.map((r: NotificationRecipient, i: number) =>
i === idx ? { ...r, name } : r
),
}));
}
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Settings</h2>
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Require Approval</p>
<p className="text-sm text-[var(--admin-text-muted)]">New registrations must be manually approved.</p>
</div>
<button
onClick={() => setForm(f => ({ ...f, requireApproval: !f.requireApproval }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.requireApproval ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.requireApproval ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Wholesale Portal</p>
<p className="text-sm text-[var(--admin-text-muted)]">Show the Wholesale Portal card on this brand&apos;s storefront page.</p>
</div>
<button
onClick={() => setForm(f => ({ ...f, wholesaleEnabled: !f.wholesaleEnabled }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.wholesaleEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.wholesaleEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="flex items-center justify-between rounded-xl border border-[var(--admin-border)] bg-[var(--admin-bg-subtle)] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-accent-light)]">
<svg className="h-4 w-4 text-[var(--admin-accent)]" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<div>
<p className="font-medium text-[var(--admin-text-primary)]">Square Sync for Wholesale</p>
<p className="text-xs text-[var(--admin-text-muted)]">
When enabled, wholesale product changes are automatically pushed to Square.
{form.squareSyncEnabled
? " Auto-sync is active."
: " Auto-sync is disabled — changes will not be pushed to Square."}
</p>
</div>
</div>
<button
onClick={() => setForm(f => ({ ...f, squareSyncEnabled: !f.squareSyncEnabled }))}
className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors ${form.squareSyncEnabled ? "bg-[var(--admin-accent)]" : "bg-[var(--admin-border)]"}`}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${form.squareSyncEnabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Minimum Order Amount ($)</label>
<input type="number" value={form.minOrderAmount} onChange={e => setForm(f => ({ ...f, minOrderAmount: 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)]" step="0.01" placeholder="No minimum" />
</div>
<div>
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">From Email</label>
<input type="email" value={form.fromEmail} onChange={e => setForm(f => ({ ...f, fromEmail: 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">Pickup Location</label>
<textarea value={form.pickupLocation} onChange={e => setForm(f => ({ ...f, pickupLocation: 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)] font-mono" rows={3} />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">FOB Location</label>
<input value={form.fobLocation} onChange={e => setForm(f => ({ ...f, fobLocation: 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">Invoice Business Name</label>
<input value={form.invoiceBusinessName} onChange={e => setForm(f => ({ ...f, invoiceBusinessName: 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">Invoice Business Address</label>
<textarea value={form.invoiceBusinessAddress} onChange={e => setForm(f => ({ ...f, invoiceBusinessAddress: 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 className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Invoice Business Phone</label>
<input value={form.invoiceBusinessPhone} onChange={e => setForm(f => ({ ...f, invoiceBusinessPhone: 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">Invoice Business Email</label>
<input type="email" value={form.invoiceBusinessEmail} onChange={e => setForm(f => ({ ...f, invoiceBusinessEmail: 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">Invoice Business Website</label>
<input value={form.invoiceBusinessWebsite} onChange={e => setForm(f => ({ ...f, invoiceBusinessWebsite: 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)]" placeholder="https://" />
</div>
{/* Team Notification Recipients */}
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1">Team Notification Recipients</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
These team members receive all wholesale notifications for this brand new orders,
deposits, fulfillments, price sheets, and pickup reminders. If no recipients are
active, the system falls back to any configured notification email on file.
</p>
{/* Empty state */}
{form.notificationRecipients.length === 0 && (
<div className="text-center py-6 mb-4 rounded-xl bg-white ring-1 ring-[var(--admin-border)]">
<p className="text-sm text-[var(--admin-text-muted)] mb-1">No recipients added yet.</p>
<p className="text-xs text-[var(--admin-text-muted)]">Add an email address below to get started.</p>
</div>
)}
{/* Recipients list */}
<div className="space-y-2 mb-4">
{form.notificationRecipients.map((r: NotificationRecipient, idx: number) => (
<div key={idx} className={`flex items-center gap-2 rounded-xl px-3 py-2.5 bg-white ring-1 ${r.active ? "ring-[var(--admin-border)]" : "ring-[var(--admin-border-light)] opacity-70"}`}>
<input
type="checkbox" checked={r.active} onChange={() => toggleRecipient(idx)}
className="rounded border-[var(--admin-border)] mt-0.5" title={r.active ? "Active — receives notifications" : "Inactive"} />
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${r.active ? "text-[var(--admin-text-primary)]" : "text-[var(--admin-text-muted)]"}`}>{r.email}</p>
{r.name && <p className="text-xs text-[var(--admin-text-muted)] truncate">{r.name}</p>}
{r.notification_types && r.notification_types.length > 0 && (
<p className="text-xs text-[var(--admin-text-secondary)] mt-0.5" title="Per-type filtering coming soon">Types: {r.notification_types.join(", ")}</p>
)}
</div>
<input
type="text" placeholder="Name (optional)"
value={r.name ?? ""}
onChange={e => updateRecipientName(idx, e.target.value)}
className="rounded-xl border border-[var(--admin-border)] px-2.5 py-1.5 text-xs w-36 outline-none focus:border-[var(--admin-accent)]" />
<button onClick={() => removeRecipient(idx)}
className="text-[var(--admin-danger)] hover:text-[var(--admin-danger)] text-xs font-medium px-2 py-1 rounded-lg hover:bg-[var(--admin-danger-light)] transition-colors"
title="Remove recipient">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{/* Add recipient */}
<AddRecipientForm onAdd={addRecipient} />
</div>
{/* Webhook Settings */}
<div className="col-span-2 rounded-2xl bg-[var(--admin-bg-subtle)] p-5 ring-1 ring-[var(--admin-border)]">
<label className="block text-sm font-semibold text-[var(--admin-text-primary)] mb-1">Webhook Integration</label>
<p className="text-xs text-[var(--admin-text-muted)] mb-4 leading-relaxed">
Send order events to external systems (Harvest Point, ERPs, etc.). Payload is signed
with HMAC-SHA256. Enable and configure the URL and secret below.
</p>
<WebhookSettingsSection brandId={brandId} onMsg={onMsg} />
</div>
</div>
<AdminButton onClick={handleSave} disabled={saving} variant="primary" isLoading={saving}>
{saving ? "Saving..." : "Save Settings"}
</AdminButton>
</div>
</div>
</div>
);
}