Add multi-vendor capability with admin vendor management

- Add resolveVendorId() helper — admin can pass ?vendorId= to scope
  catalog operations to any vendor; other roles locked to JWT vendorId
- Thread ?vendorId= through products, categories, taxes, events routes
- Add DELETE /vendors/:id (admin only) with cascade-safe guard:
  blocks if vendor has users or transactions; otherwise cascade-deletes
  EventProduct → EventTax → Event → Product → Tax → Category → Vendor
- Rewrite VendorPage: admin gets full CRUD list, vendor gets own settings
- Add VendorFilter shared component (admin-only dropdown)
- Integrate VendorFilter into Catalog, Users, and Events pages so admin
  can switch vendor context for all create/read operations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 07:59:58 -05:00
parent 65eb405cf1
commit e1b1a82e07
11 changed files with 379 additions and 195 deletions

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { useAuth } from "../context/AuthContext";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
import { VendorFilter } from "../components/VendorFilter";
interface Category { id: string; name: string; }
interface Tax { id: string; name: string; rate: number; }
@@ -16,31 +18,36 @@ interface ApiList<T> { data: T[]; }
type Tab = "products" | "categories" | "taxes";
export default function CatalogPage() {
const { user } = useAuth();
const [tab, setTab] = useState<Tab>("products");
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
</div>
<div style={tabs}>
{(["products", "categories", "taxes"] as Tab[]).map((t) => (
<button
key={t}
style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }}
onClick={() => setTab(t)}
>
<button key={t} style={{ ...tabBtn, ...(tab === t ? tabBtnActive : {}) }} onClick={() => setTab(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === "products" && <ProductsTab />}
{tab === "categories" && <CategoriesTab />}
{tab === "taxes" && <TaxesTab />}
{tab === "products" && <ProductsTab vendorId={vendorId} />}
{tab === "categories" && <CategoriesTab vendorId={vendorId} />}
{tab === "taxes" && <TaxesTab vendorId={vendorId} />}
</div>
);
}
function qs(vendorId: string) {
return vendorId ? `?vendorId=${encodeURIComponent(vendorId)}` : "";
}
// ─── Products ──────────────────────────────────────────────────────────────
function ProductsTab() {
function ProductsTab({ vendorId }: { vendorId: string }) {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [taxes, setTaxes] = useState<Tax[]>([]);
@@ -53,16 +60,17 @@ function ProductsTab() {
const load = useCallback(async () => {
setLoading(true);
const q = qs(vendorId);
const [p, c, t] = await Promise.all([
api.get<ApiList<Product>>("/products"),
api.get<ApiList<Category>>("/categories"),
api.get<ApiList<Tax>>("/taxes"),
api.get<ApiList<Product>>(`/products${q}`),
api.get<ApiList<Category>>(`/categories${q}`),
api.get<ApiList<Tax>>(`/taxes${q}`),
]);
setProducts(p.data);
setCategories(c.data);
setTaxes(t.data);
setLoading(false);
}, []);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
@@ -70,8 +78,7 @@ function ProductsTab() {
const openEdit = (p: Product) => {
setSelected(p);
setForm({ name: p.name, sku: p.sku ?? "", price: String(p.price), categoryId: p.category?.id ?? "", taxId: p.tax?.id ?? "", description: p.description ?? "" });
setError("");
setModal("edit");
setError(""); setModal("edit");
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -80,8 +87,9 @@ function ProductsTab() {
setError("");
try {
const payload = { ...form, price: parseFloat(form.price), categoryId: form.categoryId || null, taxId: form.taxId || null };
if (modal === "create") await api.post("/products", payload);
else if (selected) await api.put(`/products/${selected.id}`, payload);
const q = qs(vendorId);
if (modal === "create") await api.post(`/products${q}`, payload);
else if (selected) await api.put(`/products/${selected.id}${q}`, payload);
setModal(null);
load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
@@ -110,9 +118,7 @@ function ProductsTab() {
return (
<>
<div style={{ marginBottom: 16 }}>
<Btn onClick={openCreate}>+ Add Product</Btn>
</div>
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Product</Btn></div>
<Table columns={columns} data={products} keyField="id" loading={loading} />
{modal && (
<Modal title={modal === "create" ? "Add Product" : "Edit Product"} onClose={() => setModal(null)}>
@@ -122,12 +128,8 @@ function ProductsTab() {
<input style={inputStyle} value={form.name} onChange={f("name", setForm)} required />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="SKU">
<input style={inputStyle} value={form.sku} onChange={f("sku", setForm)} />
</FormField>
<FormField label="Price" required>
<input style={inputStyle} type="number" min="0" step="0.01" value={form.price} onChange={f("price", setForm)} required />
</FormField>
<FormField label="SKU"><input style={inputStyle} value={form.sku} onChange={f("sku", setForm)} /></FormField>
<FormField label="Price" required><input style={inputStyle} type="number" min="0" step="0.01" value={form.price} onChange={f("price", setForm)} required /></FormField>
</div>
<FormField label="Description">
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} value={form.description} onChange={f("description", setForm)} />
@@ -158,7 +160,7 @@ function ProductsTab() {
}
// ─── Categories ────────────────────────────────────────────────────────────
function CategoriesTab() {
function CategoriesTab({ vendorId }: { vendorId: string }) {
const [items, setItems] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
@@ -169,10 +171,10 @@ function CategoriesTab() {
const load = useCallback(async () => {
setLoading(true);
const res = await api.get<ApiList<Category>>("/categories");
const res = await api.get<ApiList<Category>>(`/categories${qs(vendorId)}`);
setItems(res.data);
setLoading(false);
}, []);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
@@ -181,13 +183,12 @@ function CategoriesTab() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
setSaving(true); setError("");
try {
if (modal === "create") await api.post("/categories", { name });
else if (selected) await api.put(`/categories/${selected.id}`, { name });
setModal(null);
load();
const q = qs(vendorId);
if (modal === "create") await api.post(`/categories${q}`, { name });
else if (selected) await api.put(`/categories/${selected.id}${q}`, { name });
setModal(null); load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
@@ -225,7 +226,7 @@ function CategoriesTab() {
}
// ─── Taxes ─────────────────────────────────────────────────────────────────
function TaxesTab() {
function TaxesTab({ vendorId }: { vendorId: string }) {
const [items, setItems] = useState<Tax[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
@@ -236,10 +237,10 @@ function TaxesTab() {
const load = useCallback(async () => {
setLoading(true);
const res = await api.get<ApiList<Tax>>("/taxes");
const res = await api.get<ApiList<Tax>>(`/taxes${qs(vendorId)}`);
setItems(res.data);
setLoading(false);
}, []);
}, [vendorId]);
useEffect(() => { load(); }, [load]);
@@ -248,14 +249,13 @@ function TaxesTab() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
setSaving(true); setError("");
try {
const payload = { name: form.name, rate: parseFloat(form.rate) };
if (modal === "create") await api.post("/taxes", payload);
else if (selected) await api.put(`/taxes/${selected.id}`, payload);
setModal(null);
load();
const q = qs(vendorId);
if (modal === "create") await api.post(`/taxes${q}`, payload);
else if (selected) await api.put(`/taxes/${selected.id}${q}`, payload);
setModal(null); load();
} catch (err) { setError(err instanceof Error ? err.message : "Save failed"); }
finally { setSaving(false); }
};
@@ -311,20 +311,6 @@ const errStyle: React.CSSProperties = {
color: "var(--color-danger)", borderRadius: "var(--radius)",
padding: "10px 12px", fontSize: 13, marginBottom: 16,
};
const tabs: React.CSSProperties = {
display: "flex", gap: 4, marginBottom: 20,
borderBottom: "1px solid var(--color-border)", paddingBottom: 0,
};
const tabBtn: React.CSSProperties = {
padding: "8px 16px", background: "none", border: "none",
borderBottom: "2px solid transparent", cursor: "pointer",
fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)",
marginBottom: -1,
};
const tabBtnActive: React.CSSProperties = {
color: "var(--color-primary)",
borderBottomColor: "var(--color-primary)",
};
const tabs: React.CSSProperties = { display: "flex", gap: 4, marginBottom: 20, borderBottom: "1px solid var(--color-border)" };
const tabBtn: React.CSSProperties = { padding: "8px 16px", background: "none", border: "none", borderBottom: "2px solid transparent", cursor: "pointer", fontWeight: 500, fontSize: 14, color: "var(--color-text-muted)", marginBottom: -1 };
const tabBtnActive: React.CSSProperties = { color: "var(--color-primary)", borderBottomColor: "var(--color-primary)" };