- 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>
317 lines
15 KiB
TypeScript
317 lines
15 KiB
TypeScript
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; }
|
|
interface Product {
|
|
id: string; name: string; sku: string | null; price: number;
|
|
category: Category | null; tax: Tax | null; description: string | null;
|
|
}
|
|
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" }}>
|
|
<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)}>
|
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{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({ vendorId }: { vendorId: string }) {
|
|
const [products, setProducts] = useState<Product[]>([]);
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [taxes, setTaxes] = useState<Tax[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modal, setModal] = useState<"create" | "edit" | null>(null);
|
|
const [selected, setSelected] = useState<Product | null>(null);
|
|
const [form, setForm] = useState(emptyProduct());
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
const q = qs(vendorId);
|
|
const [p, c, t] = await Promise.all([
|
|
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]);
|
|
|
|
const openCreate = () => { setSelected(null); setForm(emptyProduct()); setError(""); setModal("create"); };
|
|
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");
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
const payload = { ...form, price: parseFloat(form.price), categoryId: form.categoryId || null, taxId: form.taxId || null };
|
|
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"); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const handleDelete = async (p: Product) => {
|
|
if (!confirm(`Delete "${p.name}"?`)) return;
|
|
await api.delete(`/products/${p.id}`).catch((e) => alert(e.message));
|
|
load();
|
|
};
|
|
|
|
const columns = [
|
|
{ key: "name", header: "Name" },
|
|
{ key: "sku", header: "SKU", render: (p: Product) => p.sku ?? "—" },
|
|
{ key: "price", header: "Price", render: (p: Product) => `$${p.price.toFixed(2)}` },
|
|
{ key: "category", header: "Category", render: (p: Product) => p.category?.name ?? "—" },
|
|
{ key: "tax", header: "Tax", render: (p: Product) => p.tax ? `${p.tax.name} (${p.tax.rate}%)` : "—" },
|
|
{ key: "actions", header: "", render: (p: Product) => (
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn variant="ghost" onClick={() => openEdit(p)} style={{ padding: "4px 10px" }}>Edit</Btn>
|
|
<Btn variant="danger" onClick={() => handleDelete(p)} style={{ padding: "4px 10px" }}>Delete</Btn>
|
|
</div>
|
|
)},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<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)}>
|
|
<form onSubmit={handleSubmit}>
|
|
{error && <div style={errStyle}>{error}</div>}
|
|
<FormField label="Name" required>
|
|
<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>
|
|
</div>
|
|
<FormField label="Description">
|
|
<textarea style={{ ...inputStyle, resize: "vertical", minHeight: 60 }} value={form.description} onChange={f("description", setForm)} />
|
|
</FormField>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
|
|
<FormField label="Category">
|
|
<select style={inputStyle} value={form.categoryId} onChange={f("categoryId", setForm)}>
|
|
<option value="">None</option>
|
|
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
</select>
|
|
</FormField>
|
|
<FormField label="Tax">
|
|
<select style={inputStyle} value={form.taxId} onChange={f("taxId", setForm)}>
|
|
<option value="">None</option>
|
|
{taxes.map((t) => <option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>)}
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
|
|
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── Categories ────────────────────────────────────────────────────────────
|
|
function CategoriesTab({ vendorId }: { vendorId: string }) {
|
|
const [items, setItems] = useState<Category[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modal, setModal] = useState<"create" | "edit" | null>(null);
|
|
const [selected, setSelected] = useState<Category | null>(null);
|
|
const [name, setName] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
const res = await api.get<ApiList<Category>>(`/categories${qs(vendorId)}`);
|
|
setItems(res.data);
|
|
setLoading(false);
|
|
}, [vendorId]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const openCreate = () => { setSelected(null); setName(""); setError(""); setModal("create"); };
|
|
const openEdit = (c: Category) => { setSelected(c); setName(c.name); setError(""); setModal("edit"); };
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSaving(true); setError("");
|
|
try {
|
|
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); }
|
|
};
|
|
|
|
const columns = [
|
|
{ key: "name", header: "Name" },
|
|
{ key: "actions", header: "", render: (c: Category) => (
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn variant="ghost" onClick={() => openEdit(c)} style={{ padding: "4px 10px" }}>Edit</Btn>
|
|
<Btn variant="danger" onClick={async () => { if (!confirm(`Delete "${c.name}"?`)) return; await api.delete(`/categories/${c.id}`).catch((e) => alert(e.message)); load(); }} style={{ padding: "4px 10px" }}>Delete</Btn>
|
|
</div>
|
|
)},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Category</Btn></div>
|
|
<Table columns={columns} data={items} keyField="id" loading={loading} />
|
|
{modal && (
|
|
<Modal title={modal === "create" ? "Add Category" : "Edit Category"} onClose={() => setModal(null)}>
|
|
<form onSubmit={handleSubmit}>
|
|
{error && <div style={errStyle}>{error}</div>}
|
|
<FormField label="Name" required>
|
|
<input style={inputStyle} value={name} onChange={(e) => setName(e.target.value)} required autoFocus />
|
|
</FormField>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
|
|
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── Taxes ─────────────────────────────────────────────────────────────────
|
|
function TaxesTab({ vendorId }: { vendorId: string }) {
|
|
const [items, setItems] = useState<Tax[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modal, setModal] = useState<"create" | "edit" | null>(null);
|
|
const [selected, setSelected] = useState<Tax | null>(null);
|
|
const [form, setForm] = useState({ name: "", rate: "" });
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
const res = await api.get<ApiList<Tax>>(`/taxes${qs(vendorId)}`);
|
|
setItems(res.data);
|
|
setLoading(false);
|
|
}, [vendorId]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
const openCreate = () => { setSelected(null); setForm({ name: "", rate: "" }); setError(""); setModal("create"); };
|
|
const openEdit = (t: Tax) => { setSelected(t); setForm({ name: t.name, rate: String(t.rate) }); setError(""); setModal("edit"); };
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSaving(true); setError("");
|
|
try {
|
|
const payload = { name: form.name, rate: parseFloat(form.rate) };
|
|
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); }
|
|
};
|
|
|
|
const columns = [
|
|
{ key: "name", header: "Name" },
|
|
{ key: "rate", header: "Rate", render: (t: Tax) => `${t.rate}%` },
|
|
{ key: "actions", header: "", render: (t: Tax) => (
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn variant="ghost" onClick={() => openEdit(t)} style={{ padding: "4px 10px" }}>Edit</Btn>
|
|
<Btn variant="danger" onClick={async () => { if (!confirm(`Delete "${t.name}"?`)) return; await api.delete(`/taxes/${t.id}`).catch((e) => alert(e.message)); load(); }} style={{ padding: "4px 10px" }}>Delete</Btn>
|
|
</div>
|
|
)},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<div style={{ marginBottom: 16 }}><Btn onClick={openCreate}>+ Add Tax Rate</Btn></div>
|
|
<Table columns={columns} data={items} keyField="id" loading={loading} />
|
|
{modal && (
|
|
<Modal title={modal === "create" ? "Add Tax Rate" : "Edit Tax Rate"} onClose={() => setModal(null)}>
|
|
<form onSubmit={handleSubmit}>
|
|
{error && <div style={errStyle}>{error}</div>}
|
|
<FormField label="Name" required>
|
|
<input style={inputStyle} value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required autoFocus placeholder="e.g. GST" />
|
|
</FormField>
|
|
<FormField label="Rate (%)" required>
|
|
<input style={inputStyle} type="number" min="0" max="100" step="0.01" value={form.rate} onChange={(e) => setForm((f) => ({ ...f, rate: e.target.value }))} required placeholder="e.g. 10" />
|
|
</FormField>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
|
|
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
function emptyProduct() {
|
|
return { name: "", sku: "", price: "", categoryId: "", taxId: "", description: "" };
|
|
}
|
|
|
|
function f(key: string, set: React.Dispatch<React.SetStateAction<Record<string, string>>>) {
|
|
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
|
set((prev) => ({ ...prev, [key]: e.target.value }));
|
|
}
|
|
|
|
const errStyle: React.CSSProperties = {
|
|
background: "#fef2f2", border: "1px solid #fecaca",
|
|
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)" };
|
|
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)" };
|