Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
1501 lines
44 KiB
TypeScript
1501 lines
44 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,
|
||
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 (
|
||
<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 text-center font-bold tabular-nums leading-tight break-all",
|
||
adminMoneyDisplayClass(display, { size: "sm", emphasize: true }),
|
||
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";
|
||
}
|
||
}
|
||
|
||
/** 站点/代理卡片内 label | amount 行,金额过长时可换行 */
|
||
export function DashboardStatRow({
|
||
label,
|
||
value,
|
||
valueClassName,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
valueClassName?: string;
|
||
}): ReactElement {
|
||
return (
|
||
<div className="flex items-start justify-between gap-3 text-sm">
|
||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||
<span
|
||
className={cn(
|
||
"min-w-0 break-all text-right font-semibold tabular-nums leading-tight",
|
||
valueClassName ?? "text-foreground",
|
||
)}
|
||
>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */
|
||
export function DashboardSignedStatRow({
|
||
label,
|
||
amountMinor,
|
||
currencyCode,
|
||
}: {
|
||
label: string;
|
||
amountMinor: number;
|
||
currencyCode: string | null;
|
||
}): ReactElement {
|
||
return (
|
||
<div className="flex items-start justify-between gap-3 text-sm">
|
||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||
<SignedMoney
|
||
amount={amountMinor}
|
||
emphasize
|
||
className="min-w-0 break-all text-right leading-tight"
|
||
>
|
||
{formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
|
||
</SignedMoney>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 规模/授信等栅格指标,金额可换行 */
|
||
export function DashboardScopeMetric({
|
||
label,
|
||
value,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
}): ReactElement {
|
||
return (
|
||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
|
||
<p className="text-xs text-muted-foreground">{label}</p>
|
||
<AdminMoneyDisplay
|
||
as="p"
|
||
value={value}
|
||
size="md"
|
||
className="mt-1 text-foreground"
|
||
>
|
||
{value}
|
||
</AdminMoneyDisplay>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 财务概览区紧凑 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 = (
|
||
<div
|
||
className={cn(
|
||
"flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4",
|
||
href && "transition-colors group-hover/kpi:border-primary/30 group-hover/kpi:shadow-sm",
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<div
|
||
className={cn(
|
||
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
|
||
kpiAccentClass(accent),
|
||
)}
|
||
>
|
||
{icon}
|
||
</div>
|
||
{href ? (
|
||
<ChevronRightIcon
|
||
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
|
||
aria-hidden
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<p
|
||
title={valueTitle}
|
||
className={cn(
|
||
"mt-2 text-foreground",
|
||
resolvedValueClassName,
|
||
typeof resolvedValue === "string"
|
||
? adminMoneyDisplayClass(resolvedValue, { size: "lg", emphasize: true })
|
||
: "min-w-0 break-all text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
|
||
)}
|
||
>
|
||
{resolvedValue}
|
||
</p>
|
||
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
|
||
{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={cn(
|
||
"mt-2 text-[11px] leading-snug",
|
||
href ? "text-primary/80 group-hover/kpi:underline" : "text-muted-foreground",
|
||
)}
|
||
>
|
||
{hint}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
|
||
if (!href) {
|
||
return card;
|
||
}
|
||
|
||
return (
|
||
<Link
|
||
href={href}
|
||
className="group/kpi block h-full min-h-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
{card}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent
|
||
className={cn(
|
||
variant === "buttons" ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3",
|
||
)}
|
||
>
|
||
{links.map((link) => {
|
||
if (variant === "buttons") {
|
||
return (
|
||
<Link
|
||
key={link.href}
|
||
href={link.href}
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground",
|
||
)}
|
||
>
|
||
{link.label}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
const Icon = link.icon;
|
||
return (
|
||
<Link
|
||
key={link.href}
|
||
href={link.href}
|
||
className="flex flex-col gap-2 rounded-xl border bg-muted/20 px-4 py-4 transition-colors hover:bg-muted/40"
|
||
>
|
||
{Icon ? <Icon className="size-5 text-primary" aria-hidden /> : null}
|
||
<span className="text-sm font-medium">{link.label}</span>
|
||
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
|
||
</Link>
|
||
);
|
||
})}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
/** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */
|
||
export function DashboardReportsTeaser({
|
||
title,
|
||
description,
|
||
href = "/admin/reports",
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
href?: string;
|
||
}): ReactElement {
|
||
const { t } = useTranslation("dashboard");
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 pb-2">
|
||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||
<Link
|
||
href={href}
|
||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||
>
|
||
{t("viewReports")}
|
||
<ArrowRightIcon className="size-3.5" aria-hidden />
|
||
</Link>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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>
|
||
{typeof value === "string" || typeof value === "number" ? (
|
||
<AdminMoneyDisplay
|
||
as="p"
|
||
value={String(value)}
|
||
size="xl"
|
||
emphasize
|
||
className="mt-1 text-foreground"
|
||
>
|
||
{value}
|
||
</AdminMoneyDisplay>
|
||
) : (
|
||
<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" />
|
||
) : typeof value === "string" || typeof value === "number" ? (
|
||
<AdminMoneyDisplay
|
||
as="p"
|
||
value={String(value)}
|
||
size="xl"
|
||
emphasize
|
||
className="mt-1 text-foreground"
|
||
>
|
||
{value}
|
||
</AdminMoneyDisplay>
|
||
) : (
|
||
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
|
||
{value}
|
||
</p>
|
||
)}
|
||
{subtitle && !loading ? (
|
||
<div className="mt-2 text-xs leading-snug text-muted-foreground">{subtitle}</div>
|
||
) : 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 (
|
||
<AdminNoResourceState className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50" />
|
||
);
|
||
}
|
||
|
||
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) {
|
||
const lockedLabel = formatMoney(locked, currency);
|
||
const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset");
|
||
|
||
return (
|
||
<div className="space-y-2.5">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="rounded-lg bg-primary/5 px-2.5 py-2 ring-1 ring-primary/15">
|
||
<p className="text-[10px] font-medium uppercase tracking-wide text-primary/80">
|
||
{t("platformLockedLabel")}
|
||
</p>
|
||
<p
|
||
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
|
||
title={lockedLabel}
|
||
>
|
||
{lockedLabel}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg bg-muted/50 px-2.5 py-2 ring-1 ring-border/60">
|
||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||
{t("platformCapLabel")}
|
||
</p>
|
||
<p
|
||
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
|
||
title={capLabel}
|
||
>
|
||
{capLabel}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<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>
|
||
</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>
|
||
<p className="text-center text-sm tabular-nums">
|
||
<span className="text-muted-foreground">{t("houseGross")} </span>
|
||
<SignedMoney amount={gross} emphasize>
|
||
{formatDashboardSignedMoneyMinor(gross, currency)}
|
||
</SignedMoney>
|
||
</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 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 <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={compact ? 92 : 112}
|
||
tickLine={false}
|
||
axisLine={false}
|
||
tickFormatter={(value) => {
|
||
const row = chartData.find((item) => item.number === value);
|
||
return row?.displayNumber ?? value;
|
||
}}
|
||
tick={{ fontSize: 11, fontFamily: "var(--font-mono)" }}
|
||
/>
|
||
<ChartTooltip
|
||
content={
|
||
<ChartTooltipContent
|
||
formatter={(value, _name, item) => {
|
||
const payload = item?.payload as { number?: string } | undefined;
|
||
const number = payload?.number ? `${payload.number} · ` : "";
|
||
return `${number}${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>
|
||
);
|
||
}
|