"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 (
{cells.map((cell) => { const { display, title } = formatDashboardMetricAmount( cell.amount, currency, formatMoney, ); return (

{cell.label}

{display}

); })}
); } 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 (
{icon}

{label}

{value}

{deltaLabel ?
{deltaLabel}
: null}
{sparklineValues && sparklineValues.length >= 2 ? (
) : null} {hint ? (

{hint}

) : null}
); } 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 ( ); } 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 = (
{icon}

{label}

{value}

{deltaLabel ? (

{deltaLabel}

) : null}
{hint ?? "\u00a0"}
{sparklineValues ? ( ) : href ? ( ) : null}
); const shellClass = "flex h-full min-h-0 rounded-2xl"; if (!href) { return
{card}
; } return ( {card} ); } 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 (
{icon}

{title}

{loading ? ( ) : (

{value}

)} {subtitle && !loading ? (

{subtitle}

) : null}
{hasFooter ? (
{loading ? ( ) : (
{children}
)}
) : null}
); } /** 异常转账 KPI 底部:待办提示或正常态 */ export function AbnormalTransferPanelFooter({ total, walletPermission = true, }: { total: number | null; walletPermission?: boolean; }): ReactElement { const { t } = useTranslation("dashboard"); if (!walletPermission) { return (

{t("warnings.walletPermission")}

); } if (total == null) { return (

{t("states.noData", { ns: "common" })}

); } if (total > 0) { return (

{t("abnormalTransferPending", { count: total })}

{t("abnormalTransferAction")}

); } return (

{t("abnormalTransferAllClear")}

); } /** 派彩 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 ; } 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 (
{hasPayout ? ( ) : (

{t("noPayoutYet")}

)}
); } 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 (
); } return (

{t("lockedAndCap", { locked: formatMoney(locked, currency), cap: formatMoney(cap, currency), })}

); } 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 ; } const payoutRate = ((payout / bet) * 100).toFixed(1); const chartData = [{ segment: "structure", win, jackpot, gross }]; return (
formatMoney(Number(value), currency)} /> } />

{t("payoutRateOfBet", { rate: payoutRate })}

} />
); } 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 ; } const pieData = [ { key: "win", value: win, fill: "var(--color-win)" }, { key: "jackpot", value: jackpot, fill: "var(--color-jackpot)" }, ]; return ( formatMoney(Number(value), currency)} /> } /> {pieData.map((entry) => ( ))} {compact ? null : ( } /> )} ); } 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 ; } const chartHeight = compact ? Math.min(220, Math.max(120, rows.length * 22 + 36)) : Math.min(420, Math.max(160, rows.length * 32 + 48)); return ( `${Number(value).toFixed(1)}%`} /> } /> {chartData.map((entry) => ( ))} ); } 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 ; } const pieData = entries .filter((key) => buckets[key] > 0) .map((key) => ({ key, value: buckets[key], fill: `var(--color-${key})`, })); return ( } /> {pieData.map((entry) => ( ))} } /> ); } 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 = (

{pending_review}

{t("batchPending")}

{published}

{t("batchPublished")}

{total}

{t("batchTotal")}

); if (compact) { return statCells; } return (
{statCells}
); } 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 (

{pendingReviewTotal}

{t("batchPending")}

{publishedTotal}

{t("batchPublished")}

{pendingDrawCount > 0 ? pendingDrawCount : batchTotal}

{pendingDrawCount > 0 ? t("batchPendingDraws") : t("batchTotal")}

); } 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 ; } 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 (
{!hasPayout ? (

{t("platformNoPayoutYet")}

) : null}
); } export function SettlementStatusChart({ finance, }: { finance: AdminDrawFinanceSummaryData; }): ReactElement { const { t } = useTranslation("dashboard"); const settlementBatches = finance.settlement_batches; const entries = useMemo(() => { const counts = new Map(); 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 ; } const chartData = entries.map(([status, count]) => ({ status, count, fill: settlementBarColor(status), })); const chartHeight = Math.min(360, Math.max(140, entries.length * 40 + 40)); return ( } /> {chartData.map((entry) => ( ))} ); }