29d9d23a26
- CartContext: lazy initializers replace mount-only useEffect hydration; remove 8 no-initialize-state warnings - Toast/AdminSearchInput: React 19 useContext/use + drop forwardRef (3 no-react19-deprecated-apis) - ProductFormModal: lazy initializers + useSyncExternalStore for mount; parent adds key=editingProduct.id - InstallPrompt: useReducer for prompt state (no-cascading-set-state) - QRScanModal: ref-based latest-callback pattern replaces useEffectEvent deps mistake - OnboardingFlow: functional setState (rerender-functional-setstate) - UsersPage/StopsCalendar/FeaturesAndStats: lazy initializers (rerender-lazy-state-init) - FAQClientPage: server-side brand settings fetch via getBrandSettingsPublic in layout; remove supabase import - LandingPageWrapper: href='#' → href='#top' (anchor-is-valid) - TuxedoVideoHero: replace animate-bounce with ease-out-expo (no-inline-bounce-easing) - ProductTableClient: useCallback for handleDeleted (jsx-no-new-function-as-prop) - excel-parser: pre-compile delimiter regexes (js-hoist-regexp) - water-log/settings: Promise.all for parallel DB calls (async-parallel) - ToastNotification: extract toast store to separate file (only-export-components) - WholesaleClient: inline <WholesaleIcon/> instead of hoisting to const (rendering-hoist-jsx)
264 lines
14 KiB
TypeScript
264 lines
14 KiB
TypeScript
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 type="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 type="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 type="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>
|
|
);
|
|
} |