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,540 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AdminButton, AdminSearchInput } from "@/components/admin/design-system";
|
||||
import {
|
||||
type WholesaleOrder,
|
||||
type WholesaleCustomer,
|
||||
bulkFulfillWholesaleOrders,
|
||||
bulkRecordWholesaleDeposit,
|
||||
markWholesaleOrderFulfilled,
|
||||
recordWholesaleDeposit,
|
||||
updateWholesaleOrderStatus,
|
||||
deleteWholesaleOrder,
|
||||
getWholesaleSettings,
|
||||
enqueueWholesaleNotification,
|
||||
} from "@/actions/wholesale";
|
||||
import OrderDetailsModal from "@/components/wholesale/OrderDetailsModal";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
import type { MsgFn } from "./types";
|
||||
|
||||
interface OrdersTabProps {
|
||||
orders: WholesaleOrder[];
|
||||
customers: WholesaleCustomer[];
|
||||
brandId: string;
|
||||
onMsg: MsgFn;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// Orders tab — list/filter orders, fulfill, record deposits, bulk operations,
|
||||
// cancel/delete, generate manifests, and dispatch notification emails on fulfillment.
|
||||
export default function OrdersTab({ orders, customers, brandId, onMsg, onRefresh }: OrdersTabProps) {
|
||||
const [showDepForm, setShowDepForm] = useState<string | null>(null);
|
||||
const [depAmount, setDepAmount] = useState("");
|
||||
const [depMethod, setDepMethod] = useState("cash");
|
||||
const [fulfilling, setFulfilling] = useState<string | null>(null);
|
||||
const [manifestLoading, setManifestLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [bulkDepAmount, setBulkDepAmount] = useState("");
|
||||
const [bulkDepMethod, setBulkDepMethod] = useState("cash");
|
||||
const [showBulkDep, setShowBulkDep] = useState(false);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const [openActions, setOpenActions] = useState<string | null>(null);
|
||||
const [showViewOrder, setShowViewOrder] = useState<WholesaleOrder | null>(null);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const filtered = orders.filter(o => {
|
||||
if (statusFilter !== "all" && o.status !== statusFilter) return false;
|
||||
if (dateFrom && (!o.anticipated_pickup_date || o.anticipated_pickup_date < dateFrom)) return false;
|
||||
if (dateTo && (!o.anticipated_pickup_date || o.anticipated_pickup_date > dateTo)) return false;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (!(o.company_name?.toLowerCase().includes(q) || o.invoice_number?.toLowerCase().includes(q) || o.customer_email?.toLowerCase().includes(q))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
}
|
||||
function toggleAll() {
|
||||
if (selected.size === filtered.length) { setSelected(new Set()); }
|
||||
else { setSelected(new Set(filtered.map(o => o.id))); }
|
||||
}
|
||||
|
||||
async function handleBulkFulfill() {
|
||||
if (selected.size === 0) return;
|
||||
setBulkLoading(true);
|
||||
const result = await bulkFulfillWholesaleOrders(Array.from(selected));
|
||||
setBulkLoading(false);
|
||||
if (result.success) {
|
||||
onMsg("success", `${result.count} order(s) marked as fulfilled.`);
|
||||
setSelected(new Set());
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Bulk fulfill failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkDeposit() {
|
||||
if (selected.size === 0 || !bulkDepAmount) return;
|
||||
setBulkLoading(true);
|
||||
const result = await bulkRecordWholesaleDeposit(Array.from(selected), Number(bulkDepAmount), bulkDepMethod);
|
||||
setBulkLoading(false);
|
||||
if (result.success) {
|
||||
onMsg("success", `Deposit recorded on ${result.count} order(s).`);
|
||||
setSelected(new Set());
|
||||
setShowBulkDep(false);
|
||||
setBulkDepAmount("");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Bulk deposit failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFulfill(orderId: string) {
|
||||
setFulfilling(orderId);
|
||||
const result = await markWholesaleOrderFulfilled(orderId);
|
||||
setFulfilling(null);
|
||||
if (result.success) {
|
||||
onMsg("success", "Order marked as fulfilled.");
|
||||
onRefresh();
|
||||
|
||||
// Enqueue order fulfilled notification
|
||||
const order = orders.find(o => o.id === orderId);
|
||||
const customer = order ? customers.find(c => c.email === order.customer_email) : null;
|
||||
if (order && customer) {
|
||||
enqueueWholesaleNotification({
|
||||
brandId: brandId,
|
||||
customerId: customer.id,
|
||||
orderId,
|
||||
type: "order_fulfilled",
|
||||
emailTo: customer.email,
|
||||
subject: `Order ${order.invoice_number ?? orderId.slice(0, 8)} Ready for Pickup`,
|
||||
bodyHtml: `
|
||||
<h2>Your Order is Ready!</h2>
|
||||
<p>Hi ${customer.company_name},</p>
|
||||
<p>Your wholesale order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}.</p>
|
||||
<p>Thank you for your business!</p>
|
||||
`,
|
||||
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} has been fulfilled and is ready for pickup${order.anticipated_pickup_date ? ` on ${order.anticipated_pickup_date}` : ""}. Thank you!`,
|
||||
});
|
||||
|
||||
// Also notify the admin
|
||||
const settings = await getWholesaleSettings(brandId);
|
||||
const adminEmail = settings?.notification_email ?? settings?.from_email ?? settings?.invoice_business_email ?? null;
|
||||
if (adminEmail) {
|
||||
enqueueWholesaleNotification({
|
||||
brandId: brandId,
|
||||
customerId: customer.id,
|
||||
orderId,
|
||||
type: "order_fulfilled",
|
||||
emailTo: adminEmail,
|
||||
subject: `[Admin] Order Fulfilled — ${order.invoice_number ?? orderId.slice(0, 8)}`,
|
||||
bodyHtml: `
|
||||
<h2>Order Fulfilled</h2>
|
||||
<p>Order <strong>${order.invoice_number ?? orderId.slice(0, 8)}</strong> for <strong>${customer.company_name}</strong> has been marked as fulfilled.</p>
|
||||
${order.anticipated_pickup_date ? `<p><strong>Pickup Date:</strong> ${order.anticipated_pickup_date}</p>` : ""}
|
||||
`,
|
||||
bodyText: `Order ${order.invoice_number ?? orderId.slice(0, 8)} for ${customer.company_name} has been fulfilled.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger notification send (fire-and-forget)
|
||||
fetch(`/api/wholesale/notifications/send`, { method: "POST" }).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to fulfill order.");
|
||||
}
|
||||
}
|
||||
|
||||
// Close actions dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (!(e.target as Element).closest(".actions-cell")) {
|
||||
setOpenActions(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
|
||||
function toggleActions(orderId: string, e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setOpenActions(prev => prev === orderId ? null : orderId);
|
||||
}
|
||||
|
||||
async function handleRecordDeposit(orderId: string) {
|
||||
const result = await recordWholesaleDeposit(orderId, Number(depAmount), depMethod);
|
||||
setShowDepForm(null);
|
||||
setDepAmount("");
|
||||
if (result.success) {
|
||||
onMsg("success", "Deposit recorded.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to record deposit.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelOrder(orderId: string) {
|
||||
if (!confirm("Cancel this order? This cannot be undone.")) return;
|
||||
const result = await updateWholesaleOrderStatus(orderId, "cancelled");
|
||||
if (result.success) {
|
||||
onMsg("success", "Order cancelled.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to cancel order.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteOrder(orderId: string) {
|
||||
if (!confirm("Delete this order? Fulfilled and paid orders cannot be deleted. This cannot be undone.")) return;
|
||||
setDeleting(orderId);
|
||||
const result = await deleteWholesaleOrder(orderId);
|
||||
setDeleting(null);
|
||||
if (result.success) {
|
||||
onMsg("success", "Order deleted.");
|
||||
onRefresh();
|
||||
} else {
|
||||
onMsg("error", result.error ?? "Failed to delete order.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-[var(--admin-text-primary)]">Wholesale Orders ({orders.length})</h2>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-2xl bg-white border border-[var(--admin-border)] p-4 shadow-sm">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Status</label>
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]">
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="awaiting_deposit">Awaiting Deposit</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup From</label>
|
||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Pickup To</label>
|
||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||||
className="rounded-lg border border-[var(--admin-border)] px-3 py-1.5 text-sm outline-none focus:border-[var(--admin-accent)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<label className="block text-xs font-medium text-[var(--admin-text-muted)] mb-1">Search</label>
|
||||
<AdminSearchInput
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onClear={() => setSearchQuery("")}
|
||||
placeholder="Invoice, customer, email..."
|
||||
/>
|
||||
</div>
|
||||
{filtered.length !== orders.length && (
|
||||
<div className="self-end pb-1">
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">{filtered.length} of {orders.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-2xl bg-[var(--admin-accent-light)] border border-[var(--admin-accent)] px-4 py-3">
|
||||
<span className="text-sm font-medium text-[var(--admin-accent-text)]">{selected.size} selected</span>
|
||||
<AdminButton variant="primary" size="sm" onClick={handleBulkFulfill} disabled={bulkLoading} isLoading={bulkLoading}>
|
||||
{bulkLoading ? "..." : "Bulk Fulfill"}
|
||||
</AdminButton>
|
||||
<AdminButton variant="secondary" size="sm" onClick={() => setShowBulkDep(true)}>
|
||||
Bulk Record Deposit
|
||||
</AdminButton>
|
||||
<button onClick={() => setSelected(new Set())} className="ml-auto text-xs text-[var(--admin-text-muted)] hover:text-[var(--admin-text-secondary)]">Clear selection</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manifest section */}
|
||||
<div className="rounded-2xl bg-white border border-[var(--admin-border)] p-4 flex items-center gap-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--admin-text-secondary)]">Load Manifest</p>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">Generate a printable manifest for pending/active orders.</p>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setManifestLoading(true);
|
||||
const pending = filtered.filter(o => o.fulfillment_status !== "fulfilled");
|
||||
if (pending.length === 0) { setManifestLoading(false); return; }
|
||||
const html = await fetch("/api/wholesale/manifest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, orders: pending }),
|
||||
}).then(r => r.text());
|
||||
const w = window.open("", "_blank");
|
||||
if (w) { w.document.write(html); w.document.close(); }
|
||||
setManifestLoading(false);
|
||||
}}
|
||||
disabled={manifestLoading}
|
||||
isLoading={manifestLoading}
|
||||
>
|
||||
{manifestLoading ? "Generating..." : "Generate Manifest"}
|
||||
</AdminButton>
|
||||
</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 w-10">
|
||||
<input type="checkbox" checked={selected.size === filtered.length && filtered.length > 0} onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Invoice</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Customer</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Pickup Date</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Subtotal</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Deposit</th>
|
||||
<th className="px-5 py-3 font-semibold text-right">Balance</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Payment</th>
|
||||
<th className="px-5 py-3 font-semibold text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border-light)]">
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan={10} className="py-8 text-center text-[var(--admin-text-muted)]">No orders match the current filters.</td></tr>
|
||||
) : filtered.map(o => (
|
||||
<tr key={o.id} className={`hover:bg-[var(--admin-bg-subtle)] ${selected.has(o.id) ? "bg-[var(--admin-accent-light)]" : ""}`}>
|
||||
<td className="px-5 py-3">
|
||||
<input type="checkbox" checked={selected.has(o.id)} onChange={() => toggleSelect(o.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-[var(--admin-text-muted)]">{o.invoice_number ?? "—"}</td>
|
||||
<td className="px-5 py-3 font-medium text-[var(--admin-text-primary)]">{o.company_name}</td>
|
||||
<td className="px-5 py-3 text-[var(--admin-text-secondary)]">{o.anticipated_pickup_date ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-[var(--admin-text-primary)]">${Number(o.subtotal).toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right text-[var(--admin-text-secondary)]">${Number(o.deposit_paid).toFixed(2)} / ${Number(o.deposit_required).toFixed(2)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<span className={Number(o.balance_due) > 0 ? "text-[var(--admin-warning)] font-medium" : "text-[var(--admin-accent)]"}>
|
||||
${Number(o.balance_due).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3"><StatusBadge status={o.status} /></td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`text-xs font-medium ${o.payment_status === "paid" ? "text-[var(--admin-accent)]" : "text-[var(--admin-warning)]"}`}>
|
||||
{o.payment_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 actions-cell relative">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Primary: Mark Fulfilled — only when order is not yet fulfilled */}
|
||||
{o.fulfillment_status !== "fulfilled" && (
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => !fulfilling && handleFulfill(o.id)}
|
||||
disabled={fulfilling === o.id}
|
||||
isLoading={fulfilling === o.id}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
{fulfilling === o.id ? "..." : "Fulfill"}
|
||||
</AdminButton>
|
||||
)}
|
||||
|
||||
{/* Primary: Record Deposit — when there's a balance due */}
|
||||
{Number(o.balance_due) > 0 && o.fulfillment_status !== "fulfilled" && (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowDepForm(o.id)}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-9-9h18" /></svg>
|
||||
Deposit
|
||||
</AdminButton>
|
||||
)}
|
||||
|
||||
{/* Invoice download — always visible as icon button */}
|
||||
<a
|
||||
href={`/api/wholesale/invoice/${o.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Download Invoice"
|
||||
className="inline-flex items-center justify-center rounded-xl w-9 h-9 text-[var(--admin-text-muted)] hover:text-[var(--admin-accent)] hover:bg-[var(--admin-bg-subtle)] transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 110 12H15M6 2h9l4 4v14a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/></svg>
|
||||
</a>
|
||||
|
||||
{/* ⋮ Actions dropdown — opens upward to avoid table cutoff */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => toggleActions(o.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>
|
||||
|
||||
{openActions === o.id && (
|
||||
<div
|
||||
className="absolute bottom-full right-0 mb-1 z-30 w-56 rounded-xl bg-white shadow-xl ring-1 ring-[var(--admin-border)] py-1 text-sm"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setOpenActions(null); setShowViewOrder(o); }}
|
||||
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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
||||
View / Edit Order
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenActions(null);
|
||||
const w = window.open("", "_blank");
|
||||
if (w) {
|
||||
fetch("/api/wholesale/manifest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ brandId, orders: [o] }),
|
||||
}).then(r => r.text()).then(html => { w.document.write(html); w.document.close(); });
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h9m-9-6h6m-3 12a9 9 0 01-18 0 9 9 0 0118 0z"/></svg>
|
||||
Generate Manifest
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpenActions(null);
|
||||
fetch("/api/wholesale/price-sheet", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ customerIds: [o.customer_id], brandId }),
|
||||
}).then(r => r.json()).then(d => onMsg("success", `Price sheet sent to customer.`)).catch(() => onMsg("error", "Failed to send price sheet."));
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 text-[var(--admin-text-secondary)] hover:bg-[var(--admin-bg-subtle)] flex items-center gap-3"
|
||||
>
|
||||
<svg className="w-4 h-4 text-[var(--admin-text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.75}><path strokeLinecap="round" strokeLinejoin="round" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
||||
Send Price Sheet
|
||||
</button>
|
||||
|
||||
<div className="my-1 border-t border-[var(--admin-border)]" />
|
||||
|
||||
{o.status !== "cancelled" && o.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={() => { setOpenActions(null); handleCancelOrder(o.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"
|
||||
>
|
||||
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/></svg>
|
||||
Cancel Order
|
||||
</button>
|
||||
)}
|
||||
|
||||
{o.fulfillment_status !== "fulfilled" && (
|
||||
<button
|
||||
onClick={() => { setOpenActions(null); handleDeleteOrder(o.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"
|
||||
>
|
||||
<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>
|
||||
Delete Order
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Deposit modal */}
|
||||
{showDepForm && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Record Deposit</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount ($)</label>
|
||||
<input type="number" value={depAmount} onChange={e => setDepAmount(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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
|
||||
<select value={depMethod} onChange={e => setDepMethod(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="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="card">Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<AdminButton onClick={() => handleRecordDeposit(showDepForm)} variant="primary">
|
||||
Record Deposit
|
||||
</AdminButton>
|
||||
<AdminButton onClick={() => setShowDepForm(null)} variant="secondary">
|
||||
Cancel
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Deposit modal */}
|
||||
{showBulkDep && (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 w-96 shadow-xl border border-[var(--admin-border)]">
|
||||
<h3 className="font-semibold text-[var(--admin-text-primary)] mb-4">Bulk Record Deposit ({selected.size} orders)</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Amount per order ($)</label>
|
||||
<input type="number" value={bulkDepAmount} onChange={e => setBulkDepAmount(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="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--admin-text-secondary)] mb-1">Method</label>
|
||||
<select value={bulkDepMethod} onChange={e => setBulkDepMethod(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="cash">Cash</option>
|
||||
<option value="check">Check</option>
|
||||
<option value="wire">Wire</option>
|
||||
<option value="card">Card</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<AdminButton onClick={handleBulkDeposit} variant="primary" disabled={!bulkDepAmount || bulkLoading} isLoading={bulkLoading}>
|
||||
{bulkLoading ? "Recording..." : "Record on All"}
|
||||
</AdminButton>
|
||||
<AdminButton onClick={() => setShowBulkDep(false)} variant="secondary">
|
||||
Cancel
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Details modal */}
|
||||
{showViewOrder && (
|
||||
<OrderDetailsModal
|
||||
order={showViewOrder}
|
||||
onClose={() => setShowViewOrder(null)}
|
||||
onFulfill={handleFulfill}
|
||||
onRecordDeposit={(id) => { setShowViewOrder(null); setShowDepForm(id); }}
|
||||
fulfilling={fulfilling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user