Files
lotteryAdmin/src/modules/dashboard/dashboard-visuals.tsx
kang ce27a3ec8a feat(admin, i18n): enhance admin dashboard and user management with new features and translations
Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
2026-06-03 10:07:51 +08:00

1190 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import type { ReactElement, ReactNode } from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowRightIcon, CheckCircle2, ChevronRightIcon } from "lucide-react";
import {
Bar,
BarChart,
Cell,
Label,
Pie,
PieChart,
PolarAngleAxis,
RadialBar,
RadialBarChart,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import {
coerceAdminMinor,
formatAdminMinorDecimal,
getAdminCurrencyDecimalPlaces,
} from "@/lib/money";
import { cn } from "@/lib/utils";
import {
buildBatchProgressConfig,
buildFinanceStructureConfig,
buildPayoutPieConfig,
buildSoldOutPieConfig,
buildSettlementBarConfig,
buildUsageBarConfig,
DASHBOARD_CHART_COLORS,
} from "@/modules/dashboard/dashboard-chart-config";
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDashboardLifetimeFinance } from "@/types/api/admin-dashboard";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type {
AdminDashboardDrawPanel,
AdminDashboardResultBatchQueue,
AdminDashboardSoldOutBuckets,
} from "@/types/api/admin-dashboard";
export type SoldOutBuckets = AdminDashboardSoldOutBuckets;
type MoneyFormatter = (minor: number, currency: string | null) => string;
type DashboardFinanceMetricCell = {
key: string;
label: string;
amount: number;
emphasize: boolean;
};
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
function formatDashboardMetricAmount(
minor: number,
currencyCode: string | null,
formatMoney: MoneyFormatter,
): { display: string; title: string } {
const safeMinor = coerceAdminMinor(minor);
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
return {
display: formatAdminMinorDecimal(safeMinor, code, decimals),
title: formatMoney(safeMinor, currencyCode),
};
}
function DashboardFinanceMetricCells({
cells,
currency,
formatMoney,
}: {
cells: readonly DashboardFinanceMetricCell[];
currency: string | null;
formatMoney: MoneyFormatter;
}): ReactElement {
return (
<div className="grid grid-cols-3 gap-1.5">
{cells.map((cell) => {
const { display, title } = formatDashboardMetricAmount(
cell.amount,
currency,
formatMoney,
);
return (
<div
key={cell.key}
className={cn(
"min-w-0 rounded-lg px-1 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="line-clamp-2 text-center text-[10px] leading-tight text-muted-foreground">
{cell.label}
</p>
<p
className={cn(
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
title={title}
>
{display}
</p>
</div>
);
})}
</div>
);
}
function usageBarFill(pct: number): string {
if (pct >= 95) {
return DASHBOARD_CHART_COLORS.rose;
}
if (pct >= 80) {
return DASHBOARD_CHART_COLORS.warning;
}
return DASHBOARD_CHART_COLORS.primary;
}
function capUsageFill(pct: number): string {
if (pct >= 90) {
return "var(--destructive)";
}
if (pct >= 70) {
return DASHBOARD_CHART_COLORS.warning;
}
return DASHBOARD_CHART_COLORS.primary;
}
function settlementBarColor(status: string): string {
switch (status) {
case "pending_review":
return DASHBOARD_CHART_COLORS.warning;
case "approved":
return "oklch(0.62 0.14 230)";
case "paid":
case "completed":
return DASHBOARD_CHART_COLORS.success;
case "running":
return DASHBOARD_CHART_COLORS.primary;
case "rejected":
case "failed":
return DASHBOARD_CHART_COLORS.rose;
default:
return DASHBOARD_CHART_COLORS.violet;
}
}
type DashboardKpiAccent = "primary" | "destructive" | "muted";
function kpiAccentClass(accent: DashboardKpiAccent): string {
switch (accent) {
case "destructive":
return "bg-destructive/10 text-destructive";
case "muted":
return "bg-muted text-muted-foreground";
default:
return "bg-primary/10 text-primary";
}
}
/** 财务概览区紧凑 KPI避免 StatCard 在窄栅格内撑破布局 */
export function DashboardKpiCard({
label,
value,
hint,
icon,
accent = "primary",
sparklineValues,
deltaLabel,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: DashboardKpiAccent;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
return (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
<div className="flex min-w-0 items-start gap-3">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg",
kpiAccentClass(accent),
)}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
{value}
</p>
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
</div>
</div>
{sparklineValues && sparklineValues.length >= 2 ? (
<div className="mt-3 flex justify-end">
<MiniSparkline
values={sparklineValues}
strokeClass={
accent === "destructive"
? "stroke-destructive"
: accent === "muted"
? "stroke-muted-foreground"
: "stroke-primary"
}
/>
</div>
) : null}
{hint ? (
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
) : null}
</div>
);
}
function MiniSparkline({
values,
strokeClass,
}: {
values: number[];
strokeClass: string;
}): ReactElement | null {
if (values.length < 2) {
return null;
}
const width = 88;
const height = 32;
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = Math.max(max - min, 1);
const points = values
.map((v, i) => {
const x = (i / (values.length - 1)) * width;
const y = height - ((v - min) / range) * (height - 4) - 2;
return `${x},${y}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-8 w-[5.5rem] shrink-0"
aria-hidden
>
<polyline
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
points={points}
className={strokeClass}
/>
</svg>
);
}
export function StatCard({
label,
value,
hint,
icon,
accent = "primary",
href,
sparklineValues,
deltaLabel,
}: {
label: string;
value: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: "primary" | "destructive" | "muted";
/** 整张卡片可点击跳转 */
href?: string;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
const accentClass =
accent === "destructive"
? "bg-destructive text-destructive-foreground"
: accent === "muted"
? "bg-muted text-foreground"
: "bg-primary text-primary-foreground";
const card = (
<Card
className={cn(
"flex h-full flex-col border-border/80 py-0 shadow-sm transition-colors",
href &&
"group-hover/stat border-primary/30 bg-muted/15 shadow-md",
)}
>
<CardContent className="flex flex-1 items-start gap-4 p-5">
<div
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-lg shadow-sm",
accentClass,
)}
>
{icon}
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{value}
</p>
{deltaLabel ? (
<p className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</p>
) : null}
<div
className={cn(
"mt-auto min-h-10 pt-2 text-xs leading-snug",
hint
? href
? "font-medium text-primary group-hover/stat:underline"
: "text-muted-foreground"
: "text-transparent",
)}
>
{hint ?? "\u00a0"}
</div>
</div>
{sparklineValues ? (
<MiniSparkline
values={sparklineValues}
strokeClass={
accent === "destructive"
? "stroke-destructive"
: accent === "muted"
? "stroke-muted-foreground"
: "stroke-primary"
}
/>
) : href ? (
<ChevronRightIcon
className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 transition group-hover/stat:text-primary"
aria-hidden
/>
) : null}
</CardContent>
</Card>
);
const shellClass = "flex h-full min-h-0 rounded-2xl";
if (!href) {
return <div className={shellClass}>{card}</div>;
}
return (
<Link
href={href}
className={cn(
shellClass,
"group/stat outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{card}
</Link>
);
}
type DashboardPanelAccent = "primary" | "destructive" | "warning" | "muted";
function panelAccentClass(accent: DashboardPanelAccent): string {
switch (accent) {
case "destructive":
return "bg-destructive/10 text-destructive";
case "warning":
return "bg-amber-500/15 text-amber-700 dark:text-amber-400";
case "muted":
return "bg-muted text-muted-foreground";
default:
return "bg-primary/10 text-primary";
}
}
/** 仪表盘 KPI整卡可点主指标 + 可选底部可视化 */
export function DashboardPanelCard({
href,
icon,
title,
value,
subtitle,
actionLabel,
accent = "primary",
loading = false,
highlight = false,
children,
}: {
href: string;
title: string;
value: ReactNode;
subtitle?: ReactNode;
actionLabel: string;
icon: ReactNode;
accent?: DashboardPanelAccent;
loading?: boolean;
/** 有异常/待办时强调边框 */
highlight?: boolean;
children?: ReactNode;
}): ReactElement {
const hasFooter = children != null;
return (
<Link
href={href}
aria-label={`${title}${actionLabel}`}
className="group/panel flex h-full min-w-0 w-full rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Card
className={cn(
"admin-list-card flex h-full min-w-0 w-full flex-col py-0 transition-all duration-200",
"hover:border-primary/30 hover:shadow-md",
highlight && "border-amber-400/50 ring-1 ring-amber-400/25 dark:border-amber-500/40",
)}
>
<div className="flex flex-1 flex-col p-4">
<div className="flex items-start justify-between gap-3">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[1.125rem]",
panelAccentClass(accent),
)}
>
{icon}
</div>
<span
className="flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 transition-colors group-hover/panel:bg-primary/10 group-hover/panel:text-primary"
title={actionLabel}
>
<ArrowRightIcon
className="size-4 transition-transform group-hover/panel:translate-x-0.5"
aria-hidden
/>
</span>
</div>
<div className="mt-3 min-w-0">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-24 rounded-md" />
) : (
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{value}
</p>
)}
{subtitle && !loading ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{subtitle}
</p>
) : null}
</div>
{hasFooter ? (
<div
className={cn(
"mt-4 flex min-h-[3.25rem] items-center justify-center",
loading && "items-stretch",
)}
>
{loading ? (
<Skeleton className="h-[3.25rem] w-full rounded-lg" />
) : (
<div className="w-full border-t border-dashed border-border/60 pt-3">
{children}
</div>
)}
</div>
) : null}
</div>
</Card>
</Link>
);
}
/** 异常转账 KPI 底部:待办提示或正常态 */
export function AbnormalTransferPanelFooter({
total,
walletPermission = true,
}: {
total: number | null;
walletPermission?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
if (!walletPermission) {
return (
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] leading-snug text-muted-foreground ring-1 ring-border/50">
{t("warnings.walletPermission")}
</p>
);
}
if (total == null) {
return (
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
{t("states.noData", { ns: "common" })}
</p>
);
}
if (total > 0) {
return (
<div className="flex items-start gap-2.5 rounded-lg bg-amber-500/10 px-3 py-2.5 ring-1 ring-amber-500/20">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
<div className="min-w-0 text-left">
<p className="text-xs font-semibold text-amber-900 dark:text-amber-200">
{t("abnormalTransferPending", { count: total })}
</p>
<p className="mt-1 text-[11px] leading-snug text-amber-800/90 dark:text-amber-300/90">
{t("abnormalTransferAction")}
</p>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/8 px-3 py-2.5 ring-1 ring-emerald-500/15">
<CheckCircle2 className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
<p className="text-xs font-medium text-emerald-800 dark:text-emerald-300">
{t("abnormalTransferAllClear")}
</p>
</div>
);
}
/** 派彩 KPI 底部:投注/中奖/奖池拆分,有派彩时再附饼图 */
export function PayoutPanelSnapshot({
finance,
formatMoney,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = coerceAdminMinor(finance.total_bet_minor);
const win = coerceAdminMinor(finance.total_win_payout_minor);
const jackpot = coerceAdminMinor(finance.total_jackpot_win_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
const hasPayout = payout > 0 || win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("noFinanceActivity")} compact />;
}
const cells = [
{ key: "bet", label: t("currentDrawBetTotal"), amount: bet, emphasize: bet > 0 },
{ key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
{ key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
] as const;
return (
<div className="space-y-3">
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
{hasPayout ? (
<PayoutCompositionChart finance={finance} formatMoney={formatMoney} compact />
) : (
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
{t("noPayoutYet")}
</p>
)}
</div>
);
}
export function CapUsageBar({
locked,
cap,
usagePct,
formatMoney,
currency,
compact = false,
}: {
locked: number;
cap: number;
usagePct: number;
formatMoney: MoneyFormatter;
currency: string | null;
/** 嵌入 DashboardPanelCard 时隐藏底部说明、缩小图表 */
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const pct = Math.min(100, Math.max(0, usagePct));
const fill = capUsageFill(pct);
const chartConfig = useMemo(
() => buildUsageBarConfig(t("riskCapUsage")),
[t],
);
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
if (compact) {
return (
<div
className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("riskCapUsage")}
>
<div
className="h-full rounded-full transition-[width] duration-500"
style={{ width: `${pct}%`, backgroundColor: fill }}
/>
</div>
);
}
return (
<div className="space-y-4">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square h-[180px] w-full max-w-[200px]"
>
<RadialBarChart
data={radialData}
startAngle={90}
endAngle={-270}
innerRadius="72%"
outerRadius="100%"
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar
dataKey="usage"
background={{ fill: "var(--muted)" }}
cornerRadius={8}
fill={fill}
/>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
return null;
}
const { cx, cy } = viewBox as { cx: number; cy: number };
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan className="fill-foreground text-2xl font-bold">
{pct.toFixed(1)}%
</tspan>
</text>
);
}}
/>
</RadialBarChart>
</ChartContainer>
<p className="text-center text-xs tabular-nums text-muted-foreground">
{t("lockedAndCap", {
locked: formatMoney(locked, currency),
cap: formatMoney(cap, currency),
})}
</p>
</div>
);
}
export function FinanceStructureChart({
finance,
formatMoney,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = finance.total_bet_minor;
const win = finance.total_win_payout_minor;
const jackpot = finance.total_jackpot_win_minor;
const gross = finance.approx_house_gross_minor;
const payout = finance.total_payout_minor;
const chartConfig = useMemo(
() =>
buildFinanceStructureConfig({
win: t("winPayout"),
jackpot: t("jackpotPayout"),
gross: t("houseGross"),
}),
[t],
);
if (bet <= 0) {
return <DashboardChartEmpty message={t("noFinanceActivity")} />;
}
const payoutRate = ((payout / bet) * 100).toFixed(1);
const chartData = [{ segment: "structure", win, jackpot, gross }];
return (
<div className="space-y-4">
<ChartContainer config={chartConfig} className="aspect-auto h-12 w-full">
<BarChart
accessibilityLayer
layout="vertical"
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<XAxis type="number" hide domain={[0, bet]} />
<YAxis type="category" dataKey="segment" hide width={0} />
<ChartTooltip
content={
<ChartTooltipContent formatter={(value) => formatMoney(Number(value), currency)} />
}
/>
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
<Bar dataKey="jackpot" stackId="structure" fill="var(--color-jackpot)" radius={4} />
<Bar dataKey="gross" stackId="structure" fill="var(--color-gross)" radius={4} />
</BarChart>
</ChartContainer>
<p className="text-center text-xs text-muted-foreground">
{t("payoutRateOfBet", { rate: payoutRate })}
</p>
<ChartLegend content={<ChartLegendContent />} />
</div>
);
}
export function PayoutCompositionChart({
finance,
formatMoney,
compact = false,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const win = finance.total_win_payout_minor;
const jackpot = finance.total_jackpot_win_minor;
const total = win + jackpot;
const chartConfig = useMemo(
() =>
buildPayoutPieConfig({
win: t("winPayout"),
jackpot: t("jackpotPayout"),
}),
[t],
);
if (total <= 0) {
return <DashboardChartEmpty message={t("noPayoutYet")} compact={compact} />;
}
const pieData = [
{ key: "win", value: win, fill: "var(--color-win)" },
{ key: "jackpot", value: jackpot, fill: "var(--color-jackpot)" },
];
return (
<ChartContainer
config={chartConfig}
className={cn(
"mx-auto aspect-square w-full",
compact ? "h-[72px] max-w-[88px]" : "h-[220px] max-w-[280px]",
)}
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="key"
formatter={(value) => formatMoney(Number(value), currency)}
/>
}
/>
<Pie
data={pieData}
dataKey="value"
nameKey="key"
innerRadius="58%"
outerRadius="82%"
paddingAngle={2}
>
{pieData.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
{compact ? null : (
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
)}
</PieChart>
</ChartContainer>
);
}
export function HotUsageBars({
rows,
compact = false,
}: {
rows: AdminRiskPoolRow[];
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
const chartData = useMemo(
() =>
rows.map((row) => {
const pct = Math.min(100, Math.max(0, (row.usage_ratio ?? 0) * 100));
return {
number: row.normalized_number.trim(),
usage: pct,
fill: usageBarFill(pct),
};
}),
[rows],
);
if (rows.length === 0) {
return <DashboardChartEmpty message={t("noPoolData")} />;
}
const chartHeight = compact
? Math.min(220, Math.max(120, rows.length * 22 + 36))
: Math.min(420, Math.max(160, rows.length * 32 + 48));
return (
<ChartContainer
config={chartConfig}
className="aspect-auto w-full"
style={{ height: chartHeight }}
>
<BarChart
accessibilityLayer
layout="vertical"
data={chartData}
margin={{ top: 4, right: 12, bottom: 4, left: 4 }}
>
<XAxis type="number" domain={[0, 100]} hide />
<YAxis
type="category"
dataKey="number"
width={72}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => `${Number(value).toFixed(1)}%`}
/>
}
/>
<Bar dataKey="usage" radius={4} barSize={14}>
{chartData.map((entry) => (
<Cell key={entry.number} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ChartContainer>
);
}
export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElement {
const { t } = useTranslation("dashboard");
const chartConfig = useMemo(
() =>
buildSoldOutPieConfig({
d4: t("soldOutBuckets.d4"),
d3: t("soldOutBuckets.d3"),
d2: t("soldOutBuckets.d2"),
special: t("soldOutBuckets.special"),
other: t("soldOutBuckets.other"),
}),
[t],
);
const entries: (keyof SoldOutBuckets)[] = ["d4", "d3", "d2", "special", "other"];
const total = entries.reduce((s, key) => s + buckets[key], 0);
if (total === 0) {
return <DashboardChartEmpty message={t("noSoldOutNumbers")} />;
}
const pieData = entries
.filter((key) => buckets[key] > 0)
.map((key) => ({
key,
value: buckets[key],
fill: `var(--color-${key})`,
}));
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[240px] w-full max-w-[320px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent nameKey="key" />} />
<Pie
data={pieData}
dataKey="value"
nameKey="key"
innerRadius="58%"
outerRadius="82%"
paddingAngle={2}
>
{pieData.map((entry) => (
<Cell key={entry.key} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) {
return null;
}
const { cx, cy } = viewBox as { cx: number; cy: number };
return (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle">
<tspan className="fill-foreground text-3xl font-bold">{total}</tspan>
<tspan x={cx} dy="1.4em" className="fill-muted-foreground text-xs">
{t("soldOutTotal")}
</tspan>
</text>
);
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
</PieChart>
</ChartContainer>
);
}
export function ResultBatchProgress({
draw,
compact = false,
}: {
draw: AdminDashboardDrawPanel;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const { total, pending_review, published } = draw.result_batch_counts;
const other = Math.max(0, total - pending_review - published);
const chartConfig = useMemo(
() =>
buildBatchProgressConfig({
pending: t("batchPending"),
published: t("batchPublished"),
other: t("batchOther"),
}),
[t],
);
const chartData = [{ row: "batches", pending: pending_review, published, other }];
const statCells = (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-amber-500/8 px-2 py-2 ring-1 ring-amber-500/15">
<p
className={cn(
"font-bold tabular-nums text-amber-700 dark:text-amber-400",
compact ? "text-lg" : "text-2xl",
)}
>
{pending_review}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
</div>
<div className="rounded-lg bg-emerald-500/8 px-2 py-2 ring-1 ring-emerald-500/15">
<p
className={cn(
"font-bold tabular-nums text-emerald-700 dark:text-emerald-400",
compact ? "text-lg" : "text-2xl",
)}
>
{published}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
{total}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchTotal")}</p>
</div>
</div>
);
if (compact) {
return statCells;
}
return (
<div className="space-y-4">
<ChartContainer config={chartConfig} className="aspect-auto h-10 w-full">
<BarChart
accessibilityLayer
layout="vertical"
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<XAxis type="number" hide domain={[0, Math.max(total, 1)]} />
<YAxis type="category" dataKey="row" hide width={0} />
<Bar dataKey="pending" stackId="batch" fill="var(--color-pending)" radius={4} />
<Bar dataKey="published" stackId="batch" fill="var(--color-published)" radius={4} />
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
</BarChart>
</ChartContainer>
{statCells}
</div>
);
}
export function ResultBatchQueueSummary({
queue,
compact = false,
}: {
queue: AdminDashboardResultBatchQueue;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const pendingReviewTotal = coerceAdminMinor(queue.pending_review_total);
const pendingDrawCount = coerceAdminMinor(queue.pending_draw_count);
const publishedTotal = coerceAdminMinor(queue.published_total);
const batchTotal = coerceAdminMinor(queue.batch_total);
return (
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg bg-amber-500/8 px-2 py-2 ring-1 ring-amber-500/15">
<p
className={cn(
"font-bold tabular-nums text-amber-700 dark:text-amber-400",
compact ? "text-lg" : "text-2xl",
)}
>
{pendingReviewTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPending")}</p>
</div>
<div className="rounded-lg bg-emerald-500/8 px-2 py-2 ring-1 ring-emerald-500/15">
<p
className={cn(
"font-bold tabular-nums text-emerald-700 dark:text-emerald-400",
compact ? "text-lg" : "text-2xl",
)}
>
{publishedTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg bg-muted/50 px-2 py-2 ring-1 ring-border/60">
<p className={cn("font-bold tabular-nums text-foreground", compact ? "text-lg" : "text-2xl")}>
{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}
</p>
<p className="mt-0.5 text-[10px] text-muted-foreground">
{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}
</p>
</div>
</div>
);
}
export function PlatformLifetimePayoutSnapshot({
finance,
formatMoney,
}: {
finance: AdminDashboardLifetimeFinance;
formatMoney: MoneyFormatter;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
const bet = coerceAdminMinor(finance.total_bet_minor);
const payout = coerceAdminMinor(finance.total_payout_minor);
let win = coerceAdminMinor(finance.total_win_minor);
const jackpot = coerceAdminMinor(finance.total_jackpot_minor);
if (payout > 0 && win + jackpot === 0) {
win = payout;
}
const hasPayout = payout > 0 || win + jackpot > 0;
if (bet <= 0 && !hasPayout) {
return <DashboardChartEmpty message={t("platformNoFinanceActivity")} compact />;
}
const cells = [
{ key: "bet", label: t("platformBetTotal"), amount: bet, emphasize: bet > 0 },
{ key: "win", label: t("winPayout"), amount: win, emphasize: win > 0 },
{ key: "jackpot", label: t("jackpotPayout"), amount: jackpot, emphasize: jackpot > 0 },
] as const;
return (
<div className="space-y-3">
<DashboardFinanceMetricCells cells={cells} currency={currency} formatMoney={formatMoney} />
{!hasPayout ? (
<p className="rounded-lg bg-muted/25 px-2 py-2 text-center text-[11px] text-muted-foreground ring-1 ring-border/40">
{t("platformNoPayoutYet")}
</p>
) : null}
</div>
);
}
export function SettlementStatusChart({
finance,
}: {
finance: AdminDrawFinanceSummaryData;
}): ReactElement {
const { t } = useTranslation("dashboard");
const settlementBatches = finance.settlement_batches;
const entries = useMemo(() => {
const counts = new Map<string, number>();
for (const b of settlementBatches ?? []) {
counts.set(b.status, (counts.get(b.status) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}, [settlementBatches]);
const chartConfig = useMemo(
(): ChartConfig =>
buildSettlementBarConfig(
entries.map(([status]) => ({
status,
label: status,
color: settlementBarColor(status),
})),
),
[entries],
);
if (!settlementBatches || settlementBatches.length === 0) {
return <DashboardChartEmpty message={t("noSettlementBatches")} />;
}
const chartData = entries.map(([status, count]) => ({
status,
count,
fill: settlementBarColor(status),
}));
const chartHeight = Math.min(360, Math.max(140, entries.length * 40 + 40));
return (
<ChartContainer
config={chartConfig}
className="aspect-auto w-full"
style={{ height: chartHeight }}
>
<BarChart
accessibilityLayer
layout="vertical"
data={chartData}
margin={{ top: 4, right: 12, bottom: 4, left: 8 }}
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="status"
width={108}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="count" radius={4} barSize={16}>
{chartData.map((entry) => (
<Cell key={entry.status} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ChartContainer>
);
}