Files
lotteryAdmin/src/modules/dashboard/dashboard-visuals.tsx
kang 4484a7a77a
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Refactor agents console and related components
- Simplified share rate calculation in AgentsConsole by removing unnecessary checks and directly setting the profile share rate.
- Updated the use of `profileParentCaps` to always return total share rate in the agent profile.
- Removed unused variables and memoized calculations for improved performance.
- Cleaned up imports in various files, removing unused components and optimizing the code structure.
- Added `tRef` dependency to several useEffect hooks to ensure proper reactivity to translation changes.
- Enhanced report preview tables with better label handling for various statuses and actions.
- Updated wallet filter options to align with player-side transaction types.
- Introduced new properties in types for better type safety and clarity.
2026-06-30 17:55:00 +08:00

1501 lines
44 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,
type LucideIcon,
} from "lucide-react";
import {
Bar,
BarChart,
Cell,
Label,
Pie,
PieChart,
PolarAngleAxis,
RadialBar,
RadialBarChart,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
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 { SignedMoney, signedMoneyClass } from "@/lib/admin-signed-money";
import { adminMoneyDisplayClass } from "@/lib/admin-money-display";
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 { formatDashboardSignedMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
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 卡片底部三列:仅数字(币种见卡片主值),过长时缩小字号 + 换行 */
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 text-center font-bold tabular-nums leading-tight break-all",
adminMoneyDisplayClass(display, { size: "sm", emphasize: true }),
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";
}
}
/** 站点/代理卡片内 label | amount 行,金额过长时可换行 */
export function DashboardStatRow({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 break-all text-right font-semibold tabular-nums leading-tight",
valueClassName ?? "text-foreground",
)}
>
{value}
</span>
</div>
);
}
/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */
export function DashboardSignedStatRow({
label,
amountMinor,
currencyCode,
}: {
label: string;
amountMinor: number;
currencyCode: string | null;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<SignedMoney
amount={amountMinor}
emphasize
className="min-w-0 break-all text-right leading-tight"
>
{formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
</SignedMoney>
</div>
);
}
/** 规模/授信等栅格指标,金额可换行 */
export function DashboardScopeMetric({
label,
value,
}: {
label: string;
value: string;
}): ReactElement {
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{label}</p>
<AdminMoneyDisplay
as="p"
value={value}
size="md"
className="mt-1 text-foreground"
>
{value}
</AdminMoneyDisplay>
</div>
);
}
/** 财务概览区紧凑 KPI避免 StatCard 在窄栅格内撑破布局 */
export function DashboardKpiCard({
label,
value,
hint,
icon,
accent = "primary",
valueClassName,
signedAmountMinor,
currencyCode,
sparklineValues,
deltaLabel,
href,
}: {
label: string;
value?: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: DashboardKpiAccent;
/** 覆盖主数值颜色(如盈亏红绿) */
valueClassName?: string;
/** 盈亏类 KPI自动带 +/- 与红绿 */
signedAmountMinor?: number;
currencyCode?: string | null;
sparklineValues?: number[];
deltaLabel?: ReactNode;
/** 整张卡片可点击跳转 */
href?: string;
}): ReactElement {
const resolvedValue =
typeof signedAmountMinor === "number" && currencyCode !== undefined
? formatDashboardSignedMoneyMinor(signedAmountMinor, currencyCode)
: value;
const resolvedValueClassName =
typeof signedAmountMinor === "number"
? signedMoneyClass(signedAmountMinor, true)
: valueClassName;
const valueTitle =
typeof resolvedValue === "string" || typeof resolvedValue === "number"
? String(resolvedValue)
: undefined;
const card = (
<div
className={cn(
"flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4",
href && "transition-colors group-hover/kpi:border-primary/30 group-hover/kpi:shadow-sm",
)}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
<div className="flex shrink-0 items-center gap-1">
<div
className={cn(
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent),
)}
>
{icon}
</div>
{href ? (
<ChevronRightIcon
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
aria-hidden
/>
) : null}
</div>
</div>
<p
title={valueTitle}
className={cn(
"mt-2 text-foreground",
resolvedValueClassName,
typeof resolvedValue === "string"
? adminMoneyDisplayClass(resolvedValue, { size: "lg", emphasize: true })
: "min-w-0 break-all text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
)}
>
{resolvedValue}
</p>
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
{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={cn(
"mt-2 text-[11px] leading-snug",
href ? "text-primary/80 group-hover/kpi:underline" : "text-muted-foreground",
)}
>
{hint}
</p>
) : null}
</div>
);
if (!href) {
return card;
}
return (
<Link
href={href}
className="group/kpi block h-full min-h-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{card}
</Link>
);
}
export type DashboardQuickLink = {
href: string;
label: string;
icon?: LucideIcon;
};
/** 仪表盘快捷入口:按钮或图标卡片两种布局 */
export function DashboardQuickLinksCard({
title,
links,
variant = "buttons",
}: {
title: string;
links: readonly DashboardQuickLink[];
variant?: "buttons" | "tiles";
}): ReactElement {
const { t } = useTranslation("dashboard");
if (links.length === 0) {
return <></>;
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent
className={cn(
variant === "buttons" ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3",
)}
>
{links.map((link) => {
if (variant === "buttons") {
return (
<Link
key={link.href}
href={link.href}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground",
)}
>
{link.label}
</Link>
);
}
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className="flex flex-col gap-2 rounded-xl border bg-muted/20 px-4 py-4 transition-colors hover:bg-muted/40"
>
{Icon ? <Icon className="size-5 text-primary" aria-hidden /> : null}
<span className="text-sm font-medium">{link.label}</span>
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
</Link>
);
})}
</CardContent>
</Card>
);
}
/** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */
export function DashboardReportsTeaser({
title,
description,
href = "/admin/reports",
}: {
title: string;
description: string;
href?: string;
}): ReactElement {
const { t } = useTranslation("dashboard");
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<Link
href={href}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{t("viewReports")}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</CardHeader>
<CardContent>
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
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>
{typeof value === "string" || typeof value === "number" ? (
<AdminMoneyDisplay
as="p"
value={String(value)}
size="xl"
emphasize
className="mt-1 text-foreground"
>
{value}
</AdminMoneyDisplay>
) : (
<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" />
) : typeof value === "string" || typeof value === "number" ? (
<AdminMoneyDisplay
as="p"
value={String(value)}
size="xl"
emphasize
className="mt-1 text-foreground"
>
{value}
</AdminMoneyDisplay>
) : (
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{value}
</p>
)}
{subtitle && !loading ? (
<div className="mt-2 text-xs leading-snug text-muted-foreground">{subtitle}</div>
) : 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 (
<AdminNoResourceState className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50" />
);
}
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) {
const lockedLabel = formatMoney(locked, currency);
const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset");
return (
<div className="space-y-2.5">
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-primary/5 px-2.5 py-2 ring-1 ring-primary/15">
<p className="text-[10px] font-medium uppercase tracking-wide text-primary/80">
{t("platformLockedLabel")}
</p>
<p
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
title={lockedLabel}
>
{lockedLabel}
</p>
</div>
<div className="rounded-lg bg-muted/50 px-2.5 py-2 ring-1 ring-border/60">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t("platformCapLabel")}
</p>
<p
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
title={capLabel}
>
{capLabel}
</p>
</div>
</div>
<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>
</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>
<p className="text-center text-sm tabular-nums">
<span className="text-muted-foreground">{t("houseGross")} </span>
<SignedMoney amount={gross} emphasize>
{formatDashboardSignedMoneyMinor(gross, currency)}
</SignedMoney>
</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 shortenPoolLabel = (value: string): string => {
const normalized = value.trim();
const trimmedMeta = normalized.replace(/\s*\(.+\)$/, "");
return trimmedMeta.length > 10 ? `${trimmedMeta.slice(0, 10)}` : trimmedMeta;
};
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(),
displayNumber: shortenPoolLabel(row.normalized_number),
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={compact ? 92 : 112}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const row = chartData.find((item) => item.number === value);
return row?.displayNumber ?? value;
}}
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const payload = item?.payload as { number?: string } | undefined;
const number = payload?.number ? `${payload.number} · ` : "";
return `${number}${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>
);
}