Add Milestones 1 & 2: full-stack POS foundation with admin UI

- Node/Express/TypeScript API under /api/v1 with JWT auth (login, refresh, logout, /me)
- Prisma schema: vendors, users, roles, products, categories, taxes, transactions
- SQLite for local dev; Postgres via docker-compose for production
- Full CRUD routes for vendors, users, categories, taxes, products with Zod validation and RBAC
- Paginated list endpoints scoped per vendor; refresh token rotation
- React/TypeScript admin SPA (Vite): login, protected routing, sidebar layout
- Pages: Dashboard, Catalog (tabbed Products/Categories/Taxes), Users, Vendor Settings
- Shared UI: Table, Modal, FormField, Btn, PageHeader components
- Multi-stage Dockerfile; docker-compose with Postgres healthcheck
- Seed script with demo vendor and owner account
- INSTRUCTIONS.md, ROADMAP.md, .claude/launch.json for dev server config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 23:18:04 -05:00
parent fb62439eab
commit d53c772dd6
4594 changed files with 1876068 additions and 0 deletions

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
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 [tab, setTab] = useState<Tab>("products");
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader title="Catalog" subtitle="Products, categories, and tax rates" />
<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 />}
{tab === "categories" && <CategoriesTab />}
{tab === "taxes" && <TaxesTab />}
</div>
);
}
// ─── Products ──────────────────────────────────────────────────────────────
function ProductsTab() {
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 [p, c, t] = await Promise.all([
api.get<ApiList<Product>>("/products"),
api.get<ApiList<Category>>("/categories"),
api.get<ApiList<Tax>>("/taxes"),
]);
setProducts(p.data);
setCategories(c.data);
setTaxes(t.data);
setLoading(false);
}, []);
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 };
if (modal === "create") await api.post("/products", payload);
else if (selected) await api.put(`/products/${selected.id}`, 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() {
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");
setItems(res.data);
setLoading(false);
}, []);
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 {
if (modal === "create") await api.post("/categories", { name });
else if (selected) await api.put(`/categories/${selected.id}`, { 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() {
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");
setItems(res.data);
setLoading(false);
}, []);
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) };
if (modal === "create") await api.post("/taxes", payload);
else if (selected) await api.put(`/taxes/${selected.id}`, 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)", 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)",
};

View File

@@ -0,0 +1,47 @@
import { useAuth } from "../context/AuthContext";
import { PageHeader } from "../components/PageHeader";
const CARDS = [
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog" },
{ label: "Users", desc: "Manage roles and access", to: "/users" },
{ label: "Vendor", desc: "Business details and tax settings", to: "/vendor" },
{ label: "Reports", desc: "Sales and tax summaries", to: "/reports" },
];
export default function DashboardPage() {
const { user } = useAuth();
return (
<div style={{ padding: "32px 28px", maxWidth: 900 }}>
<PageHeader
title="Dashboard"
subtitle={`Welcome back, ${user?.name} · ${user?.vendorName}`}
/>
<div style={grid}>
{CARDS.map((card) => (
<a key={card.label} href={card.to} style={cardStyle}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{card.label}</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 13 }}>{card.desc}</div>
</a>
))}
</div>
</div>
);
}
const grid: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 16,
};
const cardStyle: React.CSSProperties = {
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "20px",
boxShadow: "var(--shadow)",
textDecoration: "none",
color: "var(--color-text)",
display: "block",
};

View File

@@ -0,0 +1,128 @@
import React, { useState, FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<div style={styles.page}>
<div style={styles.card}>
<h1 style={styles.title}>POS Admin</h1>
<p style={styles.subtitle}>Sign in to your account</p>
<form onSubmit={handleSubmit} style={styles.form}>
{error && <div style={styles.error}>{error}</div>}
<label style={styles.label}>
Email
<input
style={styles.input}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
autoComplete="email"
/>
</label>
<label style={styles.label}>
Password
<input
style={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</label>
<button style={styles.button} type="submit" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--color-bg)",
},
card: {
background: "var(--color-surface)",
borderRadius: "var(--radius)",
boxShadow: "var(--shadow)",
border: "1px solid var(--color-border)",
padding: "40px",
width: "100%",
maxWidth: "380px",
},
title: {
fontSize: "22px",
fontWeight: 700,
marginBottom: "4px",
},
subtitle: {
color: "var(--color-text-muted)",
marginBottom: "24px",
},
form: {
display: "flex",
flexDirection: "column",
gap: "16px",
},
label: {
display: "flex",
flexDirection: "column",
gap: "4px",
fontWeight: 500,
},
input: {
border: "1px solid var(--color-border)",
borderRadius: "var(--radius)",
padding: "8px 12px",
outline: "none",
fontSize: "14px",
},
button: {
background: "var(--color-primary)",
color: "#fff",
border: "none",
borderRadius: "var(--radius)",
padding: "10px",
fontWeight: 600,
fontSize: "14px",
marginTop: "4px",
},
error: {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: "13px",
},
};

View File

@@ -0,0 +1,193 @@
import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
interface Role { id: string; name: string; }
interface User {
id: string;
name: string;
email: string;
role: Role;
createdAt: string;
}
interface ApiList<T> { data: T[]; pagination: { total: number; page: number; limit: number; totalPages: number }; }
const EMPTY_FORM = { name: "", email: "", password: "", roleId: "" };
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<"create" | "edit" | null>(null);
const [selected, setSelected] = useState<User | 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 [usersRes, rolesRes] = await Promise.all([
api.get<ApiList<User>>("/users"),
api.get<Role[]>("/users/roles/list"),
]);
setUsers(usersRes.data);
setRoles(rolesRes);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setSelected(null);
setForm(EMPTY_FORM);
setError("");
setModal("create");
};
const openEdit = (user: User) => {
setSelected(user);
setForm({ name: user.name, email: user.email, password: "", roleId: user.role.id });
setError("");
setModal("edit");
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
try {
if (modal === "create") {
await api.post("/users", form);
} else if (selected) {
const patch: Record<string, string> = { name: form.name, roleId: form.roleId };
if (form.password) patch.password = form.password;
await api.put(`/users/${selected.id}`, patch);
}
setModal(null);
load();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
const handleDelete = async (user: User) => {
if (!confirm(`Delete user "${user.name}"?`)) return;
try {
await api.delete(`/users/${user.id}`);
load();
} catch (err) {
alert(err instanceof Error ? err.message : "Delete failed");
}
};
const columns = [
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{
key: "role",
header: "Role",
render: (u: User) => (
<span style={roleBadge(u.role.name)}>{u.role.name}</span>
),
},
{
key: "createdAt",
header: "Created",
render: (u: User) => new Date(u.createdAt).toLocaleDateString(),
},
{
key: "actions",
header: "",
render: (u: User) => (
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={() => openEdit(u)} style={{ padding: "4px 10px" }}>Edit</Btn>
<Btn variant="danger" onClick={() => handleDelete(u)} style={{ padding: "4px 10px" }}>Delete</Btn>
</div>
),
},
];
return (
<div style={{ padding: "32px 28px" }}>
<PageHeader
title="Users"
subtitle="Manage staff accounts and roles"
action={<Btn onClick={openCreate}>+ Add User</Btn>}
/>
<Table columns={columns} data={users} keyField="id" loading={loading} />
{modal && (
<Modal
title={modal === "create" ? "Add User" : "Edit User"}
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 />
</FormField>
{modal === "create" && (
<FormField label="Email" required>
<input style={inputStyle} type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} required />
</FormField>
)}
<FormField label={modal === "edit" ? "New Password (leave blank to keep)" : "Password"} required={modal === "create"}>
<input style={inputStyle} type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} minLength={8} required={modal === "create"} />
</FormField>
<FormField label="Role" required>
<select style={inputStyle} value={form.roleId} onChange={(e) => setForm((f) => ({ ...f, roleId: e.target.value }))} required>
<option value="">Select role</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</FormField>
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<Btn type="submit" disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
<Btn variant="ghost" onClick={() => setModal(null)}>Cancel</Btn>
</div>
</form>
</Modal>
)}
</div>
);
}
function roleBadge(role: string): React.CSSProperties {
const colors: Record<string, { bg: string; color: string }> = {
owner: { bg: "#fef3c7", color: "#92400e" },
manager: { bg: "#dbeafe", color: "#1e3a8a" },
cashier: { bg: "#f0fdf4", color: "#14532d" },
};
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
return {
display: "inline-block",
padding: "2px 10px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
textTransform: "capitalize",
...c,
};
}
const errStyle: React.CSSProperties = {
background: "#fef2f2",
border: "1px solid #fecaca",
color: "var(--color-danger)",
borderRadius: "var(--radius)",
padding: "10px 12px",
fontSize: 13,
marginBottom: 16,
};

View File

@@ -0,0 +1,129 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { PageHeader } from "../components/PageHeader";
import { FormField, inputStyle, Btn } from "../components/FormField";
interface Vendor {
id: string;
name: string;
businessNum: string | null;
taxSettings: string | null;
createdAt: string;
updatedAt: string;
}
export default function VendorPage() {
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: "" });
useEffect(() => {
api
.get<{ data: 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>
);
}
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,
};