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
509 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|