Files
pos/client/src/pages/VendorPage.tsx
jason e1b1a82e07 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>
2026-03-21 07:59:58 -05:00

239 lines
8.4 KiB
TypeScript

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 {
id: string;
name: string;
businessNum: string | null;
taxSettings: string | null;
createdAt: string;
updatedAt: string;
}
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(EMPTY_FORM);
useEffect(() => {
api.get<ApiList<Vendor>>("/vendors")
.then((res) => {
const v = res.data[0] ?? null;
setVendor(v);
if (v) setForm({ name: v.name, businessNum: v.businessNum ?? "" });
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!vendor) return;
setSaving(true);
setError("");
try {
const updated = await api.put<Vendor>(`/vendors/${vendor.id}`, form);
setVendor(updated);
setEditing(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally { setSaving(false); }
};
if (loading) return <div style={{ padding: 32 }}>Loading</div>;
if (!vendor) return <div style={{ padding: 32 }}>No vendor found.</div>;
return (
<div style={{ padding: "32px 28px", maxWidth: 600 }}>
<PageHeader
title="Vendor Settings"
subtitle="Business details and configuration"
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 />
</FormField>
<FormField label="Business Number / ABN">
<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>
</div>
</form>
) : (
<div style={card}>
<Row label="Business Name" value={vendor.name} />
<Row label="Business Number" value={vendor.businessNum ?? "—"} />
<Row label="Created" value={new Date(vendor.createdAt).toLocaleDateString()} />
<Row label="Last Updated" value={new Date(vendor.updatedAt).toLocaleDateString()} />
</div>
)}
</div>
);
}
// ─── 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)" }}>
<div style={{ width: 160, fontWeight: 500, fontSize: 13, color: "var(--color-text-muted)" }}>{label}</div>
<div style={{ flex: 1, fontSize: 14 }}>{value}</div>
</div>
);
}
const card: React.CSSProperties = {
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "20px",
};
const errStyle: React.CSSProperties = {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: 13,
marginBottom: 16,
};