"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, AdminTableNoResourceRow } 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 (
{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"; } } /** 站点/代理卡片内 label | amount 行,金额过长时可换行 */ export function DashboardStatRow({ label, value, valueClassName, }: { label: string; value: string; valueClassName?: string; }): ReactElement { return (
{label} {value}
); } /** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */ export function DashboardSignedStatRow({ label, amountMinor, currencyCode, }: { label: string; amountMinor: number; currencyCode: string | null; }): ReactElement { return (
{label} {formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
); } /** 规模/授信等栅格指标,金额可换行 */ export function DashboardScopeMetric({ label, value, }: { label: string; value: string; }): ReactElement { return (

{label}

{value}
); } /** 财务概览区紧凑 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 = (

{label}

{icon}
{href ? ( ) : null}

{resolvedValue}

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

{hint}

) : null}
); if (!href) { return card; } return ( {card} ); } 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 ( {title} {links.map((link) => { if (variant === "buttons") { return ( {link.label} ); } const Icon = link.icon; return ( {Icon ? : null} {link.label} {t("cs.openModule")} ); })} ); } /** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */ export function DashboardReportsTeaser({ title, description, href = "/admin/reports", }: { title: string; description: string; href?: string; }): ReactElement { const { t } = useTranslation("dashboard"); return ( {title} {t("viewReports")}

{description}

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

{typeof value === "string" || typeof value === "number" ? ( {value} ) : (

{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 ? ( ) : typeof value === "string" || typeof value === "number" ? ( {value} ) : (

{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 ( ); } 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) { const lockedLabel = formatMoney(locked, currency); const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset"); return (

{t("platformLockedLabel")}

{lockedLabel}

{t("platformCapLabel")}

{capLabel}

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

{t("houseGross")} {formatDashboardSignedMoneyMinor(gross, currency)}

} />
); } 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 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 ; } const chartHeight = compact ? Math.min(220, Math.max(120, rows.length * 22 + 36)) : Math.min(420, Math.max(160, rows.length * 32 + 48)); return ( { const row = chartData.find((item) => item.number === value); return row?.displayNumber ?? value; }} tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }} /> { const payload = item?.payload as { number?: string } | undefined; const number = payload?.number ? `${payload.number} · ` : ""; return `${number}${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) => ( ))} ); }