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:
53
client/src/components/VendorFilter.tsx
Normal file
53
client/src/components/VendorFilter.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
interface Vendor { id: string; name: string; }
|
||||
|
||||
interface Props {
|
||||
vendorId: string;
|
||||
onChange: (vendorId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown that lets admin users switch vendor context.
|
||||
* Renders nothing for non-admin roles.
|
||||
*/
|
||||
export function VendorFilter({ vendorId, onChange }: Props) {
|
||||
const { user } = useAuth();
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== "admin") return;
|
||||
api.get<{ data: Vendor[] }>("/vendors?limit=200")
|
||||
.then((r) => setVendors(r.data))
|
||||
.catch(console.error);
|
||||
}, [user?.role]);
|
||||
|
||||
if (user?.role !== "admin" || vendors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>Vendor:</span>
|
||||
<select
|
||||
style={sel}
|
||||
value={vendorId}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
{vendors.map((v) => (
|
||||
<option key={v.id} value={v.id}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sel: React.CSSProperties = {
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius)",
|
||||
padding: "5px 8px",
|
||||
fontSize: 13,
|
||||
background: "var(--color-surface)",
|
||||
cursor: "pointer",
|
||||
minWidth: 140,
|
||||
};
|
||||
@@ -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)" };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { VendorFilter } from "../components/VendorFilter";
|
||||
import { Table } from "../components/Table";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { FormField, Btn } from "../components/FormField";
|
||||
@@ -50,6 +52,8 @@ interface EventSummary {
|
||||
// ─── Main Page ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EventsPage() {
|
||||
const { user } = useAuth();
|
||||
const [vendorId, setVendorId] = useState(user?.vendorId ?? "");
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -61,7 +65,8 @@ export default function EventsPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<ApiList<Event>>("/events?limit=50");
|
||||
const q = vendorId && user?.role === "admin" ? `?vendorId=${encodeURIComponent(vendorId)}&limit=50` : "?limit=50";
|
||||
const res = await api.get<ApiList<Event>>(`/events${q}`);
|
||||
setEvents(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} catch (err) {
|
||||
@@ -69,7 +74,7 @@ export default function EventsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [vendorId, user?.role]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
@@ -120,11 +125,14 @@ export default function EventsPage() {
|
||||
|
||||
return (
|
||||
<div style={{ padding: "32px 28px" }}>
|
||||
<PageHeader
|
||||
title="Events"
|
||||
subtitle={`${total} event${total !== 1 ? "s" : ""}`}
|
||||
action={<Btn onClick={() => { setEditing(null); setShowForm(true); }}>+ New Event</Btn>}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 4 }}>
|
||||
<PageHeader
|
||||
title="Events"
|
||||
subtitle={`${total} event${total !== 1 ? "s" : ""}`}
|
||||
action={<Btn onClick={() => { setEditing(null); setShowForm(true); }}>+ New Event</Btn>}
|
||||
/>
|
||||
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
|
||||
</div>
|
||||
|
||||
<Table columns={columns} data={events} keyField="id" loading={loading} emptyText="No events yet." />
|
||||
|
||||
|
||||
@@ -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 Role { id: string; name: string; }
|
||||
interface User {
|
||||
@@ -11,14 +13,17 @@ interface User {
|
||||
name: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
vendor?: { id: string; name: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; limit: number; totalPages: number }; }
|
||||
|
||||
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "" };
|
||||
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "", vendorId: "" };
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: me } = useAuth();
|
||||
const [vendorId, setVendorId] = useState(me?.vendorId ?? "");
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,8 +36,9 @@ export default function UsersPage() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const q = vendorId && me?.role === "admin" ? `?vendorId=${encodeURIComponent(vendorId)}` : "";
|
||||
const [usersRes, rolesRes] = await Promise.all([
|
||||
api.get<ApiList<User>>("/users"),
|
||||
api.get<ApiList<User>>(`/users${q}`),
|
||||
api.get<Role[]>("/users/roles/list"),
|
||||
]);
|
||||
setUsers(usersRes.data);
|
||||
@@ -42,13 +48,13 @@ export default function UsersPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [vendorId, me?.role]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setSelected(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setForm({ ...EMPTY_FORM, vendorId });
|
||||
setError("");
|
||||
setModal("create");
|
||||
};
|
||||
@@ -66,7 +72,7 @@ export default function UsersPage() {
|
||||
setError("");
|
||||
try {
|
||||
if (modal === "create") {
|
||||
await api.post("/users", form);
|
||||
await api.post("/users", { ...form, vendorId: form.vendorId || undefined });
|
||||
} else if (selected) {
|
||||
const patch: Record<string, string> = { name: form.name, roleId: form.roleId };
|
||||
if (form.password) patch.password = form.password;
|
||||
@@ -91,9 +97,12 @@ export default function UsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name" },
|
||||
{ key: "email", header: "Email" },
|
||||
...(isAdmin ? [{ key: "vendor", header: "Vendor", render: (u: User) => u.vendor?.name ?? "—" }] : []),
|
||||
{
|
||||
key: "role",
|
||||
header: "Role",
|
||||
@@ -120,11 +129,14 @@ export default function UsersPage() {
|
||||
|
||||
return (
|
||||
<div style={{ padding: "32px 28px" }}>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
subtitle="Manage staff accounts and roles"
|
||||
action={<Btn onClick={openCreate}>+ Add User</Btn>}
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", flexWrap: "wrap", gap: 12, marginBottom: 16 }}>
|
||||
<PageHeader
|
||||
title="Users"
|
||||
subtitle="Manage staff accounts and roles"
|
||||
action={<Btn onClick={openCreate}>+ Add User</Btn>}
|
||||
/>
|
||||
<VendorFilter vendorId={vendorId} onChange={setVendorId} />
|
||||
</div>
|
||||
<Table columns={columns} data={users} keyField="id" loading={loading} />
|
||||
|
||||
{modal && (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { PageHeader } from "../components/PageHeader";
|
||||
import { Table } from "../components/Table";
|
||||
import { Modal } from "../components/Modal";
|
||||
import { FormField, inputStyle, Btn } from "../components/FormField";
|
||||
|
||||
interface Vendor {
|
||||
@@ -12,17 +15,131 @@ interface Vendor {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function VendorPage() {
|
||||
interface ApiList<T> { data: T[]; pagination: { total: number } }
|
||||
|
||||
const EMPTY_FORM = { name: "", businessNum: "" };
|
||||
|
||||
// ─── Admin view: list all vendors, create/edit/delete ────────────────────────
|
||||
|
||||
function AdminVendorPage() {
|
||||
const [vendors, setVendors] = useState<Vendor[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modal, setModal] = useState<"create" | "edit" | null>(null);
|
||||
const [selected, setSelected] = useState<Vendor | null>(null);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<ApiList<Vendor>>("/vendors?limit=100");
|
||||
setVendors(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} catch (err) { console.error(err); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setSelected(null);
|
||||
setForm(EMPTY_FORM);
|
||||
setError("");
|
||||
setModal("create");
|
||||
};
|
||||
|
||||
const openEdit = (v: Vendor) => {
|
||||
setSelected(v);
|
||||
setForm({ name: v.name, businessNum: v.businessNum ?? "" });
|
||||
setError("");
|
||||
setModal("edit");
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("Delete this vendor? All associated data will be removed.")) return;
|
||||
try {
|
||||
await api.delete(`/vendors/${id}`);
|
||||
load();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Delete failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (modal === "edit" && selected) {
|
||||
await api.put(`/vendors/${selected.id}`, form);
|
||||
} else {
|
||||
await api.post("/vendors", form);
|
||||
}
|
||||
setModal(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: "name", header: "Name", render: (v: Vendor) => v.name },
|
||||
{ key: "businessNum", header: "Business No.", render: (v: Vendor) => v.businessNum ?? "—" },
|
||||
{ key: "createdAt", header: "Created", render: (v: Vendor) => new Date(v.createdAt).toLocaleDateString() },
|
||||
{
|
||||
key: "actions", header: "", render: (v: Vendor) => (
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<Btn size="sm" onClick={() => openEdit(v)}>Edit</Btn>
|
||||
<Btn size="sm" variant="danger" onClick={() => handleDelete(v.id)}>Delete</Btn>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: "32px 28px" }}>
|
||||
<PageHeader
|
||||
title="Vendors"
|
||||
subtitle={`${total} vendor${total !== 1 ? "s" : ""}`}
|
||||
action={<Btn onClick={openCreate}>+ New Vendor</Btn>}
|
||||
/>
|
||||
|
||||
<Table columns={columns} data={vendors} keyField="id" loading={loading} emptyText="No vendors found." />
|
||||
|
||||
{modal && (
|
||||
<Modal title={modal === "create" ? "New Vendor" : "Edit Vendor"} onClose={() => setModal(null)}>
|
||||
<FormField label="Business Name" required>
|
||||
<input style={inputStyle} value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
|
||||
</FormField>
|
||||
<FormField label="Business Number / ABN">
|
||||
<input style={inputStyle} value={form.businessNum}
|
||||
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
|
||||
</FormField>
|
||||
{error && <div style={errStyle}>{error}</div>}
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 8 }}>
|
||||
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
|
||||
<Btn onClick={handleSave} disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Vendor/user view: own settings only ────────────────────────────────────
|
||||
|
||||
function OwnVendorPage() {
|
||||
const [vendor, setVendor] = useState<Vendor | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [form, setForm] = useState({ name: "", businessNum: "" });
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ data: Vendor[] }>("/vendors")
|
||||
api.get<ApiList<Vendor>>("/vendors")
|
||||
.then((res) => {
|
||||
const v = res.data[0] ?? null;
|
||||
setVendor(v);
|
||||
@@ -43,9 +160,7 @@ export default function VendorPage() {
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: 32 }}>Loading…</div>;
|
||||
@@ -56,38 +171,23 @@ export default function VendorPage() {
|
||||
<PageHeader
|
||||
title="Vendor Settings"
|
||||
subtitle="Business details and configuration"
|
||||
action={
|
||||
!editing && (
|
||||
<Btn onClick={() => setEditing(true)}>Edit</Btn>
|
||||
)
|
||||
}
|
||||
action={!editing && <Btn onClick={() => setEditing(true)}>Edit</Btn>}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<form onSubmit={handleSave} style={card}>
|
||||
{error && <div style={errStyle}>{error}</div>}
|
||||
<FormField label="Business Name" required>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<input style={inputStyle} value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} required />
|
||||
</FormField>
|
||||
<FormField label="Business Number / ABN">
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={form.businessNum}
|
||||
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))}
|
||||
/>
|
||||
<input style={inputStyle} value={form.businessNum}
|
||||
onChange={(e) => setForm((f) => ({ ...f, businessNum: e.target.value }))} />
|
||||
</FormField>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
|
||||
<Btn type="submit" disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={() => setEditing(false)}>
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save changes"}</Btn>
|
||||
<Btn variant="ghost" onClick={() => setEditing(false)}>Cancel</Btn>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
@@ -102,6 +202,15 @@ export default function VendorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Root export — branches on role ─────────────────────────────────────────
|
||||
|
||||
export default function VendorPage() {
|
||||
const { user } = useAuth();
|
||||
return user?.role === "admin" ? <AdminVendorPage /> : <OwnVendorPage />;
|
||||
}
|
||||
|
||||
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 16, padding: "10px 0", borderBottom: "1px solid var(--color-border)" }}>
|
||||
|
||||
Reference in New Issue
Block a user