467 lines
19 KiB
TypeScript
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 };
|