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