Files
pos/client/src/pages/EventsPage.tsx
jason 65eb405cf1 Rename roles, add multi-vendor support, and Events system
Roles: owner→admin, manager→vendor, cashier→user across all routes,
seed, and client UI. Role badge colours updated in UsersPage.

Multi-vendor:
- GET /vendors and GET /users now return all records for admin role;
  vendor/user roles remain scoped to their vendorId
- POST /users: admin can specify vendorId to assign user to any vendor
- vendors/users now include vendor name in responses for admin context

Events (new):
- Prisma schema: Event, EventTax, EventProduct models; Transaction.eventId
- POST/GET/PUT/DELETE /api/v1/events — full CRUD, vendor-scoped
- PUT /events/:id/taxes + DELETE — upsert/remove per-event tax rate overrides
- POST/GET/DELETE /events/:id/products — product allowlist (empty=all)
- GET /events/:id/transactions — paginated list scoped to event
- GET /events/:id/reports/summary — revenue, avg tx, top products for event
- Transactions: eventId accepted in both single POST and batch POST
- Catalog sync: active/upcoming events included in /catalog/sync response

Client:
- Layout nav filtered by role (user role sees Catalog only)
- Dashboard cards filtered by role
- Events page: list, create/edit modal, detail modal with Configuration
  (tax overrides + product allowlist) and Reports tabs

DB: DATABASE_URL updated to file:./prisma/dev.db in .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 07:27:30 -05:00

467 lines
19 KiB
TypeScript

import React, { useEffect, useState, useCallback } from "react";
import { api } from "../api/client";
import { PageHeader } from "../components/PageHeader";
import { Table } from "../components/Table";
import { Modal } from "../components/Modal";
import { FormField, Btn } from "../components/FormField";
// ─── Types ──────────────────────────────────────────────────────────────────
interface Vendor { id: string; name: string; }
interface EventTaxOverride {
id: string;
taxId: string;
rate: number;
tax: { name: string; rate: number };
}
interface EventProductItem {
id: string;
productId: string;
product: { id: string; name: string; price: number; sku?: string };
}
interface Event {
id: string;
name: string;
description?: string;
startsAt: string;
endsAt: string;
isActive: boolean;
vendorId: string;
vendor?: Vendor;
taxOverrides?: EventTaxOverride[];
products?: EventProductItem[];
_count?: { products: number; taxOverrides: number; transactions: number };
}
interface Tax { id: string; name: string; rate: number; }
interface Product { id: string; name: string; price: number; sku?: string; }
interface ApiList<T> { data: T[]; pagination: { total: number } }
interface EventSummary {
event: { id: string; name: string; startsAt: string; endsAt: string };
totals: { revenue: number; tax: number; discounts: number; transactionCount: number; averageTransaction: number };
byPaymentMethod: { method: string; revenue: number; count: number }[];
topProducts: { productId: string; productName: string; revenue: number; unitsSold: number }[];
}
// ─── Main Page ──────────────────────────────────────────────────────────────
export default function EventsPage() {
const [events, setEvents] = useState<Event[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<Event | null>(null);
const [detail, setDetail] = useState<Event | null>(null);
const [detailView, setDetailView] = useState<"config" | "report">("config");
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ApiList<Event>>("/events?limit=50");
setEvents(res.data);
setTotal(res.pagination.total);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openDetail = async (ev: Event) => {
try {
const full = await api.get<Event>(`/events/${ev.id}`);
setDetail(full);
setDetailView("config");
} catch (err) {
console.error(err);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this event? This cannot be undone.")) return;
await api.delete(`/events/${id}`);
load();
};
const columns = [
{ key: "name", header: "Name", render: (ev: Event) => ev.name },
{
key: "dates", header: "Dates", render: (ev: Event) =>
`${fmtDate(ev.startsAt)}${fmtDate(ev.endsAt)}`
},
{ key: "vendor", header: "Vendor", render: (ev: Event) => ev.vendor?.name ?? ev.vendorId },
{
key: "status", header: "Status", render: (ev: Event) => (
<span style={ev.isActive ? activeBadge : inactiveBadge}>
{ev.isActive ? "Active" : "Inactive"}
</span>
)
},
{
key: "counts", header: "Products / Taxes / Txns", render: (ev: Event) =>
`${ev._count?.products ?? 0} / ${ev._count?.taxOverrides ?? 0} / ${ev._count?.transactions ?? 0}`
},
{
key: "actions", header: "", render: (ev: Event) => (
<div style={{ display: "flex", gap: 6 }}>
<Btn size="sm" onClick={() => openDetail(ev)}>View</Btn>
<Btn size="sm" onClick={() => { setEditing(ev); setShowForm(true); }}>Edit</Btn>
<Btn size="sm" variant="danger" onClick={() => handleDelete(ev.id)}>Delete</Btn>
</div>
)
},
];
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>}
/>
<Table columns={columns} data={events} keyField="id" loading={loading} emptyText="No events yet." />
{showForm && (
<EventFormModal
event={editing}
onClose={() => setShowForm(false)}
onSaved={() => { setShowForm(false); load(); }}
/>
)}
{detail && (
<EventDetailModal
event={detail}
view={detailView}
onViewChange={setDetailView}
onClose={() => setDetail(null)}
onRefresh={() => openDetail(detail)}
/>
)}
</div>
);
}
// ─── Event Form Modal ────────────────────────────────────────────────────────
function EventFormModal({ event, onClose, onSaved }: {
event: Event | null;
onClose: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState(event?.name ?? "");
const [description, setDescription] = useState(event?.description ?? "");
const [startsAt, setStartsAt] = useState(event ? toDatetimeLocal(event.startsAt) : "");
const [endsAt, setEndsAt] = useState(event ? toDatetimeLocal(event.endsAt) : "");
const [isActive, setIsActive] = useState(event?.isActive ?? true);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
setError("");
try {
const body = {
name, description: description || undefined,
startsAt: new Date(startsAt).toISOString(),
endsAt: new Date(endsAt).toISOString(),
isActive,
};
if (event) {
await api.put(`/events/${event.id}`, body);
} else {
await api.post("/events", body);
}
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed");
} finally {
setSaving(false);
}
};
return (
<Modal title={event ? "Edit Event" : "New Event"} onClose={onClose}>
<FormField label="Name">
<input style={input} value={name} onChange={(e) => setName(e.target.value)} />
</FormField>
<FormField label="Description">
<textarea style={{ ...input, height: 64, resize: "vertical" }} value={description}
onChange={(e) => setDescription(e.target.value)} />
</FormField>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<FormField label="Starts At">
<input type="datetime-local" style={input} value={startsAt}
onChange={(e) => setStartsAt(e.target.value)} />
</FormField>
<FormField label="Ends At">
<input type="datetime-local" style={input} value={endsAt}
onChange={(e) => setEndsAt(e.target.value)} />
</FormField>
</div>
<FormField label="">
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input type="checkbox" checked={isActive} onChange={(e) => setIsActive(e.target.checked)} />
<span style={{ fontSize: 14 }}>Active</span>
</label>
</FormField>
{error && <div style={errStyle}>{error}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</Btn>
</div>
</Modal>
);
}
// ─── Event Detail Modal ──────────────────────────────────────────────────────
function EventDetailModal({ event, view, onViewChange, onClose, onRefresh }: {
event: Event;
view: "config" | "report";
onViewChange: (v: "config" | "report") => void;
onClose: () => void;
onRefresh: () => void;
}) {
return (
<Modal title={event.name} onClose={onClose} width={760}>
<div style={{ color: "var(--color-text-muted)", fontSize: 13, marginBottom: 16 }}>
{fmtDate(event.startsAt)} {fmtDate(event.endsAt)}
{event.description && <span style={{ marginLeft: 12 }}>· {event.description}</span>}
</div>
<div style={tabs}>
{(["config", "report"] as const).map((t) => (
<button key={t} type="button"
style={{ ...tabBtn, ...(view === t ? tabBtnActive : {}) }}
onClick={() => onViewChange(t)}>
{t === "config" ? "Configuration" : "Reports"}
</button>
))}
</div>
{view === "config" && (
<EventConfigPanel event={event} onRefresh={onRefresh} />
)}
{view === "report" && (
<EventReportPanel eventId={event.id} />
)}
</Modal>
);
}
// ─── Event Config Panel ──────────────────────────────────────────────────────
function EventConfigPanel({ event, onRefresh }: { event: Event; onRefresh: () => void }) {
const [taxes, setTaxes] = useState<Tax[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [taxId, setTaxId] = useState("");
const [taxRate, setTaxRate] = useState("");
const [productId, setProductId] = useState("");
const [err, setErr] = useState("");
useEffect(() => {
Promise.all([
api.get<ApiList<Tax>>("/taxes?limit=100").then((r) => setTaxes(r.data)),
api.get<ApiList<Product>>("/products?limit=200").then((r) => setProducts(r.data)),
]).catch(console.error);
}, []);
const addTax = async () => {
setErr("");
try {
await api.put(`/events/${event.id}/taxes`, { taxId, rate: parseFloat(taxRate) });
setTaxId(""); setTaxRate("");
onRefresh();
} catch (e) { setErr(e instanceof Error ? e.message : "Failed"); }
};
const removeTax = async (tId: string) => {
await api.delete(`/events/${event.id}/taxes/${tId}`);
onRefresh();
};
const addProduct = async () => {
setErr("");
try {
await api.post(`/events/${event.id}/products`, { productId });
setProductId("");
onRefresh();
} catch (e) { setErr(e instanceof Error ? e.message : "Failed"); }
};
const removeProduct = async (pId: string) => {
await api.delete(`/events/${event.id}/products/${pId}`);
onRefresh();
};
return (
<div>
{err && <div style={errStyle}>{err}</div>}
{/* Tax overrides */}
<div style={sectionTitle}>Tax Rate Overrides</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, marginBottom: 8 }}>
Override the default tax rate for this event. Empty = use vendor defaults.
</div>
{(event.taxOverrides ?? []).map((o) => (
<div key={o.id} style={listRow}>
<span style={{ flex: 1 }}>{o.tax.name}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 13 }}>
{o.tax.rate}% <strong>{o.rate}%</strong>
</span>
<Btn size="sm" variant="danger" onClick={() => removeTax(o.taxId)}>Remove</Btn>
</div>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8, alignItems: "center" }}>
<select style={input} value={taxId} onChange={(e) => setTaxId(e.target.value)}>
<option value="">Select tax</option>
{taxes.map((t) => <option key={t.id} value={t.id}>{t.name} ({t.rate}%)</option>)}
</select>
<input style={{ ...input, width: 90 }} placeholder="Rate %" type="number" min="0" max="100"
value={taxRate} onChange={(e) => setTaxRate(e.target.value)} />
<Btn onClick={addTax} disabled={!taxId || !taxRate}>Add</Btn>
</div>
{/* Product allowlist */}
<div style={{ ...sectionTitle, marginTop: 24 }}>Product Allowlist</div>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, marginBottom: 8 }}>
Restrict which products are available at this event. Empty = all vendor products available.
</div>
{(event.products ?? []).map((ep) => (
<div key={ep.id} style={listRow}>
<span style={{ flex: 1 }}>{ep.product.name}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 13 }}>${ep.product.price.toFixed(2)}</span>
<Btn size="sm" variant="danger" onClick={() => removeProduct(ep.productId)}>Remove</Btn>
</div>
))}
<div style={{ display: "flex", gap: 8, marginTop: 8 }}>
<select style={input} value={productId} onChange={(e) => setProductId(e.target.value)}>
<option value="">Add product</option>
{products
.filter((p) => !(event.products ?? []).find((ep) => ep.productId === p.id))
.map((p) => <option key={p.id} value={p.id}>{p.name} ${p.price.toFixed(2)}</option>)}
</select>
<Btn onClick={addProduct} disabled={!productId}>Add</Btn>
</div>
</div>
);
}
// ─── Event Report Panel ──────────────────────────────────────────────────────
function EventReportPanel({ eventId }: { eventId: string }) {
const [summary, setSummary] = useState<EventSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<EventSummary>(`/events/${eventId}/reports/summary`)
.then(setSummary)
.catch(console.error)
.finally(() => setLoading(false));
}, [eventId]);
if (loading) return <div style={{ color: "var(--color-text-muted)" }}>Loading</div>;
if (!summary) return <div style={{ color: "var(--color-text-muted)" }}>No data</div>;
const { totals, byPaymentMethod, topProducts } = summary;
return (
<div>
<div style={statGrid}>
<StatCard label="Revenue" value={`$${totals.revenue.toFixed(2)}`} />
<StatCard label="Transactions" value={String(totals.transactionCount)} />
<StatCard label="Avg Transaction" value={`$${totals.averageTransaction.toFixed(2)}`} />
<StatCard label="Tax Collected" value={`$${totals.tax.toFixed(2)}`} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div style={card}>
<div style={cardTitle}>By Payment Method</div>
{byPaymentMethod.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No sales yet</p>
: byPaymentMethod.map((m) => (
<div key={m.method} style={listRow}>
<span style={methodBadge(m.method)}>{m.method}</span>
<span style={{ marginLeft: "auto" }}>${m.revenue.toFixed(2)}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>({m.count})</span>
</div>
))
}
</div>
<div style={card}>
<div style={cardTitle}>Top Products</div>
{topProducts.length === 0
? <p style={{ color: "var(--color-text-muted)", fontSize: 13 }}>No sales yet</p>
: topProducts.map((p, i) => (
<div key={p.productId} style={listRow}>
<span style={rank}>{i + 1}</span>
<span style={{ flex: 1 }}>{p.productName}</span>
<span style={{ color: "var(--color-text-muted)", fontSize: 12 }}>{p.unitsSold} sold</span>
<span style={{ fontWeight: 600, marginLeft: 8 }}>${p.revenue.toFixed(2)}</span>
</div>
))
}
</div>
</div>
</div>
);
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div style={statCard}>
<div style={{ color: "var(--color-text-muted)", fontSize: 12, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.04em", marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 24, fontWeight: 700 }}>{value}</div>
</div>
);
}
function methodBadge(method: string): React.CSSProperties {
return {
display: "inline-block", padding: "2px 10px", borderRadius: 999,
fontSize: 12, fontWeight: 600, textTransform: "capitalize",
background: method === "cash" ? "#f0fdf4" : "#eff6ff",
color: method === "cash" ? "#166534" : "#1e40af",
};
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit" });
}
function toDatetimeLocal(iso: string) {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const input: React.CSSProperties = {
width: "100%", border: "1px solid var(--color-border)", borderRadius: "var(--radius)",
padding: "7px 10px", fontSize: 14, boxSizing: "border-box",
};
const errStyle: React.CSSProperties = { color: "#dc2626", fontSize: 13, marginBottom: 8 };
const activeBadge: React.CSSProperties = { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "#f0fdf4", color: "#166534" };
const inactiveBadge: React.CSSProperties = { display: "inline-block", padding: "2px 10px", borderRadius: 999, fontSize: 12, fontWeight: 600, background: "#f1f5f9", color: "#64748b" };
const sectionTitle: React.CSSProperties = { fontWeight: 600, fontSize: 14, marginBottom: 6 };
const listRow: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, padding: "7px 0", borderBottom: "1px solid var(--color-border)" };
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)" };
const statGrid: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 16, marginBottom: 20 };
const statCard: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const card: React.CSSProperties = { background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius)", padding: "16px 20px" };
const cardTitle: React.CSSProperties = { fontWeight: 600, marginBottom: 12, fontSize: 14 };
const rank: React.CSSProperties = { width: 20, height: 20, borderRadius: "50%", background: "#e2e8f0", fontSize: 11, fontWeight: 700, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 };