Files
lotteryAdmin/src/modules/dashboard/dashboard-analytics-panel.tsx
kang 6ea0a6feec feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support
Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color
2026-06-12 20:47:53 +08:00

509 lines
18 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
import {
DASHBOARD_ANALYTICS_PERIODS,
DASHBOARD_RANKING_METRICS,
useDashboardAnalytics,
type DashboardAnalyticsState,
} from "@/modules/dashboard/use-dashboard-analytics";
// recharts 图表组件懒加载
const DailyTrendChart = dynamic(
() => import("@/modules/dashboard/dashboard-trend-charts").then((m) => ({ default: m.DailyTrendChart })),
{ ssr: false },
);
const PlayBreakdownChart = dynamic(
() => import("@/modules/dashboard/dashboard-trend-charts").then((m) => ({ default: m.PlayBreakdownChart })),
{ ssr: false },
);
function computeDeltaPercent(series: number[]): string | null {
if (series.length < 2) {
return null;
}
const prev = series[series.length - 2];
const last = series[series.length - 1];
if (prev === 0) {
return null;
}
const pct = ((last - prev) / Math.abs(prev)) * 100;
const sign = pct >= 0 ? "▲" : "▼";
return `${sign} ${Math.abs(pct).toFixed(1)}%`;
}
function deltaClassName(series: number[]): string {
if (series.length < 2) {
return "text-muted-foreground";
}
const last = series[series.length - 1];
const prev = series[series.length - 2];
if (last >= prev) {
return "text-emerald-600 dark:text-emerald-400";
}
return "text-destructive";
}
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
period,
setPeriod,
playCode,
setPlayCode,
customFrom,
setCustomFrom,
customTo,
setCustomTo,
loading,
error,
data,
currency,
summary,
periodRangeLabel,
playFilterLabel,
playOptions,
sparklines,
formatMoney,
formatSignedMoney,
profitScope,
} = analytics;
if (!enabled) {
return null;
}
return (
<Card className="admin-list-card min-w-0 overflow-hidden py-0">
<CardHeader className="space-y-3 border-b border-border/60 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base font-semibold">{t("analytics.title")}</CardTitle>
<Link
href="/admin/reports"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 gap-1.5 text-xs")}
>
<BarChart3 className="size-3.5" aria-hidden />
{t("viewReports")}
</Link>
</div>
<div className="flex flex-wrap gap-1.5" role="group" aria-label={t("analytics.periodLabel")}>
{DASHBOARD_ANALYTICS_PERIODS.map((p) => (
<button
key={p}
type="button"
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
period === p
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-muted",
)}
onClick={() => setPeriod(p)}
>
{t(`analytics.periods.${p}`)}
</button>
))}
</div>
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-end">
{period === "custom" ? (
<AdminDateRangeField
id="dashboard-analytics-range"
label={t("analytics.customRange")}
from={customFrom}
to={customTo}
onRangeChange={({ from, to }) => {
setCustomFrom(from);
setCustomTo(to);
}}
/>
) : (
<p className="text-sm text-muted-foreground">
{periodRangeLabel
? t("analytics.rangeHint", { range: periodRangeLabel })
: t("analytics.selectPeriod")}
</p>
)}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">{t("analytics.playLabel")}</Label>
<Select
value={playCode === "" ? "__all__" : playCode}
onValueChange={(v) => setPlayCode(!v || v === "__all__" ? "" : v)}
>
<SelectTrigger className="w-full min-w-[160px]">
<SelectValue>{playFilterLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("analytics.allPlays")}</SelectItem>
{playOptions.map((p) => (
<SelectItem key={p.code} value={p.code}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 py-4">
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{data?.chart_meta.truncated ? (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("analytics.chartTruncated", {
from: data.chart_meta.chart_date_from,
to: data.chart_meta.chart_date_to,
days: data.chart_meta.span_days,
})}
</p>
) : null}
{loading ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" />
))}
</div>
) : summary ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
<DashboardKpiCard
label={t("analytics.summaryBet")}
value={formatMoney(summary.total_bet_minor, currency)}
hint={t("lifetimeActivityHint", {
draws: summary.draw_count.toLocaleString(getAdminRequestLocale()),
days: summary.business_day_count.toLocaleString(getAdminRequestLocale()),
})}
icon={<Wallet className="size-4" aria-hidden />}
sparklineValues={sparklines.bet}
deltaLabel={
computeDeltaPercent(sparklines.bet) ? (
<span className={deltaClassName(sparklines.bet)}>
{computeDeltaPercent(sparklines.bet)}
</span>
) : undefined
}
/>
<DashboardKpiCard
label={t("analytics.summaryPayout")}
value={formatMoney(summary.total_payout_minor, currency)}
hint={
summary.total_bet_minor > 0
? t("payoutRateOfBet", {
rate: ((summary.total_payout_minor / summary.total_bet_minor) * 100).toFixed(1),
})
: undefined
}
icon={<Gift className="size-4" aria-hidden />}
accent="destructive"
sparklineValues={sparklines.payout}
deltaLabel={
computeDeltaPercent(sparklines.payout) ? (
<span className={deltaClassName(sparklines.payout)}>
{computeDeltaPercent(sparklines.payout)}
</span>
) : undefined
}
/>
<DashboardKpiCard
label={
profitScope === "share_profit"
? t("analytics.summaryShareProfit")
: t("analytics.summaryProfit")
}
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
hint={
profitScope === "share_profit"
? t("analytics.shareProfitHint")
: summary.total_bet_minor > 0
? t("marginRate", {
rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
})
: undefined
}
icon={<TrendingUp className="size-4" aria-hidden />}
sparklineValues={sparklines.profit}
deltaLabel={
computeDeltaPercent(sparklines.profit) ? (
<span className={deltaClassName(sparklines.profit)}>
{computeDeltaPercent(sparklines.profit)}
</span>
) : undefined
}
/>
</div>
) : null}
<div className="rounded-xl border border-border/60 bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-2.5">
<p className="text-sm font-semibold">{t("analytics.dailyTrend")}</p>
<span className="text-xs text-muted-foreground">{t("analytics.granularityDay")}</span>
</div>
<div className="px-3 py-3">
{loading ? (
<Skeleton className="h-[260px] w-full" />
) : data ? (
<DailyTrendChart
series={data.daily_series}
metric="overview"
formatMoney={formatMoney}
currency={currency}
/>
) : (
<AdminNoResourceState className="py-10 text-center text-sm text-muted-foreground" />
)}
</div>
</div>
</CardContent>
</Card>
);
}
export function DashboardPlayRankingCard({
analytics,
}: {
analytics: DashboardAnalyticsState;
}): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
rankingMetric,
setRankingMetric,
period,
setPeriod,
loading,
data,
currency,
topPlayRows,
resolvePlayLabel,
formatMoney,
} = analytics;
if (!enabled) {
return null;
}
return (
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
<CardHeader className="space-y-3 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("analytics.playRanking")}</CardTitle>
<div className="flex flex-wrap gap-1" role="tablist" aria-label={t("analytics.rankingMetricLabel")}>
{DASHBOARD_RANKING_METRICS.map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={rankingMetric === m}
className={cn(
"rounded-md px-2 py-1 text-[11px] font-medium transition-colors",
rankingMetric === m
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setRankingMetric(m)}
>
{t(`analytics.rankingMetrics.${m}`)}
</button>
))}
</div>
<Select value={period} onValueChange={(v) => setPeriod(v as typeof period)}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{t(`analytics.periods.${period}`)}</SelectValue>
</SelectTrigger>
<SelectContent>
{DASHBOARD_ANALYTICS_PERIODS.filter((p) => p !== "custom").map((p) => (
<SelectItem key={p} value={p}>
{t(`analytics.periods.${p}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
{loading ? (
<Skeleton className="h-[200px] w-full" />
) : data && topPlayRows.length > 0 ? (
<PlayBreakdownChart
rows={topPlayRows}
metric={rankingMetric}
formatMoney={formatMoney}
currency={currency}
playLabel={resolvePlayLabel}
compact
/>
) : (
<AdminNoResourceState className="py-10" />
)}
</CardContent>
</Card>
);
}
export function DashboardAgentRankingCard({
analytics,
}: {
analytics: DashboardAnalyticsState;
}): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const {
enabled,
rankingMetric,
loading,
topAgentRows,
currency,
formatMoney,
formatSignedMoney,
} = analytics;
if (!enabled) {
return null;
}
const metricValue = (row: (typeof topAgentRows)[number]): number => {
if (rankingMetric === "payout") {
return row.total_payout_minor;
}
if (rankingMetric === "profit") {
return row.approx_house_gross_minor;
}
return row.total_bet_minor;
};
const maxAbs = Math.max(1, ...topAgentRows.map((r) => Math.abs(metricValue(r))));
const formatRowValue = (row: (typeof topAgentRows)[number]): string => {
const v = metricValue(row);
if (rankingMetric === "profit") {
return formatSignedMoney(v, currency);
}
return formatMoney(v, currency);
};
const barColor = (row: (typeof topAgentRows)[number]): string => {
if (rankingMetric === "bet") {
return DASHBOARD_CHART_COLORS.primary;
}
if (rankingMetric === "payout") {
return DASHBOARD_CHART_COLORS.rose;
}
return row.approx_house_gross_minor >= 0 ? DASHBOARD_CHART_COLORS.success : DASHBOARD_CHART_COLORS.warning;
};
return (
<Card className="admin-list-card flex min-w-0 flex-col overflow-hidden py-0">
<CardHeader className="space-y-2 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("analytics.agentRanking")}</CardTitle>
<p className="text-xs text-muted-foreground">
{t(`analytics.rankingMetrics.${rankingMetric}`)}
</p>
</CardHeader>
<CardContent className="min-w-0 flex-1 overflow-hidden px-3 py-3">
{loading ? (
<Skeleton className="h-[210px] w-full" />
) : topAgentRows.length > 0 ? (
<div className="space-y-1.5">
{topAgentRows.map((row, idx) => {
const v = metricValue(row);
const pct = (Math.abs(v) / maxAbs) * 100;
const color = barColor(row);
const agentName = row.agent_name?.trim() || "-";
const agentCode = row.agent_code?.trim() || "";
const showCode = agentCode !== "" && agentCode !== agentName;
return (
<div key={row.agent_node_id} className="rounded-lg bg-muted/20 px-2 py-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 w-5 shrink-0 text-center text-[11px] font-semibold text-muted-foreground">
#{idx + 1}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-medium">{agentName}</p>
{showCode ? (
<p className="truncate text-[11px] text-muted-foreground">{agentCode}</p>
) : null}
</div>
</div>
<div
className={cn(
"shrink-0 text-right text-xs font-semibold tabular-nums",
rankingMetric === "profit"
? signedMoneyClass(row.approx_house_gross_minor, true)
: undefined,
)}
>
{formatRowValue(row)}
</div>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-muted/30">
<div
className="h-full rounded-full"
style={{
width: `${Math.max(2, pct)}%`,
backgroundColor: color,
opacity: 0.35,
}}
/>
</div>
</div>
);
})}
</div>
) : (
<AdminNoResourceState className="py-10" />
)}
</CardContent>
</Card>
);
}
/** 单列堆叠布局(兼容旧用法) */
export function DashboardAnalyticsPanel({
enabled,
playOptions,
scope,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
scope: { siteCode: string; agentNodeId: number | undefined };
}): ReactNode {
const analytics = useDashboardAnalytics({ enabled, playOptions, scope });
return (
<section className="space-y-4">
<DashboardAnalyticsMain analytics={analytics} />
<DashboardPlayRankingCard analytics={analytics} />
</section>
);
}