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>
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import React from "react";
|
||||
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" },
|
||||
const ALL_CARDS = [
|
||||
{ label: "Catalog", desc: "Products, categories, pricing", to: "/catalog", roles: ["admin", "vendor", "user"] },
|
||||
{ label: "Events", desc: "Events, tax overrides, reports", to: "/events", roles: ["admin", "vendor"] },
|
||||
{ label: "Users", desc: "Manage roles and access", to: "/users", roles: ["admin", "vendor"] },
|
||||
{ label: "Vendor", desc: "Business details and tax settings", to: "/vendor", roles: ["admin", "vendor"] },
|
||||
{ label: "Reports", desc: "Sales and tax summaries", to: "/reports", roles: ["admin", "vendor"] },
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const cards = ALL_CARDS.filter((c) => c.roles.includes(user?.role ?? ""));
|
||||
|
||||
return (
|
||||
<div style={{ padding: "32px 28px", maxWidth: 900 }}>
|
||||
@@ -18,7 +21,7 @@ export default function DashboardPage() {
|
||||
subtitle={`Welcome back, ${user?.name} · ${user?.vendorName}`}
|
||||
/>
|
||||
<div style={grid}>
|
||||
{CARDS.map((card) => (
|
||||
{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>
|
||||
|
||||
466
client/src/pages/EventsPage.tsx
Normal file
466
client/src/pages/EventsPage.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
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 };
|
||||
@@ -166,9 +166,9 @@ export default function UsersPage() {
|
||||
|
||||
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" },
|
||||
admin: { bg: "#fef3c7", color: "#92400e" },
|
||||
vendor: { bg: "#dbeafe", color: "#1e3a8a" },
|
||||
user: { bg: "#f0fdf4", color: "#14532d" },
|
||||
};
|
||||
const c = colors[role] ?? { bg: "#f1f5f9", color: "#475569" };
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user