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.
1190 lines
34 KiB
TypeScript
1190 lines
34 KiB
TypeScript
"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>
|
||
);
|
||
}
|