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