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:
@@ -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