refactor(layout, i18n, admin): 优化布局结构与多语言支持

调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
This commit is contained in:
2026-05-30 17:46:27 +08:00
parent 36117144dc
commit a550c418e5
64 changed files with 3405 additions and 1378 deletions

View File

@@ -1,12 +1,10 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react";
import { format, subDays } from "date-fns";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, Gift, TrendingUp, Wallet } from "lucide-react";
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { buttonVariants } from "@/components/ui/button";
@@ -20,58 +18,345 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils";
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import {
DailyTrendChart,
PeriodCompareStrip,
PlayBreakdownChart,
} from "@/modules/dashboard/dashboard-trend-charts";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardAnalyticsData,
DashboardAnalyticsMetric,
DashboardAnalyticsPeriod,
} from "@/types/api/admin-dashboard-analytics";
import {
DASHBOARD_ANALYTICS_PERIODS,
DASHBOARD_RANKING_METRICS,
useDashboardAnalytics,
type DashboardAnalyticsState,
} from "@/modules/dashboard/use-dashboard-analytics";
const PERIOD_OPTIONS: DashboardAnalyticsPeriod[] = [
"today",
"last_7_days",
"last_30_days",
"this_month",
"lifetime",
"custom",
];
const METRIC_OPTIONS: DashboardAnalyticsMetric[] = ["overview", "bet", "payout", "profit"];
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
currency: code,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
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 formatSignedMoneyMinor(minor: number, currencyCode: string | null): string {
if (minor === 0) {
return formatMoneyMinor(0, currencyCode);
function deltaClassName(series: number[]): string {
if (series.length < 2) {
return "text-muted-foreground";
}
const s = minor > 0 ? "+" : "";
return `${s}${formatMoneyMinor(Math.abs(minor), currencyCode)}`;
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,
} = 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={t("analytics.summaryProfit")}
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
hint={
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}
/>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
)}
</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
/>
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("analytics.noPlayData")}
</p>
)}
</CardContent>
</Card>
);
}
/** 单列堆叠布局(兼容旧用法) */
export function DashboardAnalyticsPanel({
enabled,
playOptions,
@@ -79,309 +364,11 @@ export function DashboardAnalyticsPanel({
enabled: boolean;
playOptions: { code: string; label: string }[];
}): ReactNode {
const { t } = useTranslation(["dashboard", "common"]);
const playLabel = useAdminPlayCodeLabel();
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
const [metric, setMetric] = useState<DashboardAnalyticsMetric>("overview");
const [playCode, setPlayCode] = useState<string>("");
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
const load = useCallback(async () => {
if (!enabled) {
setLoading(false);
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const payload = await getAdminDashboardAnalytics({
period,
metric,
play_code: playCode !== "" ? playCode : undefined,
...(period === "custom"
? { date_from: customFrom, date_to: customTo }
: {}),
});
setData(payload);
} catch (e) {
setData(null);
const raw = e instanceof LotteryApiBizError ? e.message : "";
const needsAuthSync =
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
setError(
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
);
} finally {
setLoading(false);
}
}, [enabled, period, metric, playCode, customFrom, customTo, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;
const periodRangeLabel = useMemo(() => {
if (!data) {
return null;
}
return data.date_from === data.date_to
? data.date_from
: `${data.date_from}${data.date_to}`;
}, [data]);
const metricLabel = useMemo(
() => t(`analytics.metrics.${metric}`),
[metric, t],
);
const playFilterLabel = useMemo(() => {
if (playCode === "") {
return t("analytics.allPlays");
}
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
}, [playCode, playOptions, t]);
const resolvePlayLabel = useCallback(
(code: string, dimension: number) => {
const base = playLabel(code);
return dimension > 0 ? `${base} · ${dimension}D` : base;
},
[playLabel],
);
if (!enabled) {
return null;
}
const analytics = useDashboardAnalytics({ enabled, playOptions });
return (
<section className="space-y-4">
<Card className="border-border/80 shadow-sm">
<CardHeader className="space-y-4 pb-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base">{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")}>
{PERIOD_OPTIONS.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-4 lg:grid-cols-[1fr_auto_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 lg:col-span-1">
{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.metricLabel")}</Label>
<Select value={metric} onValueChange={(v) => setMetric(v as DashboardAnalyticsMetric)}>
<SelectTrigger className="w-full min-w-[140px]">
<SelectValue>{metricLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{METRIC_OPTIONS.map((m) => (
<SelectItem key={m} value={m}>
{t(`analytics.metrics.${m}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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-6">
{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 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
) : summary ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
label={t("analytics.summaryBet")}
value={formatMoneyMinor(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-5" aria-hidden />}
/>
<StatCard
label={t("analytics.summaryPayout")}
value={formatMoneyMinor(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-5" aria-hidden />}
accent="destructive"
/>
<StatCard
label={t("analytics.summaryProfit")}
value={formatSignedMoneyMinor(summary.approx_house_gross_minor, currency)}
hint={
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-5" aria-hidden />}
/>
</div>
) : null}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2 lg:items-start">
<Card className="flex h-full flex-col border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.dailyTrend")}</CardTitle>
</CardHeader>
<CardContent className="pb-4">
{loading ? (
<Skeleton className="h-[220px] w-full" />
) : data ? (
<DailyTrendChart
series={data.daily_series}
metric={metric}
formatMoney={formatMoneyMinor}
currency={currency}
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="flex h-full flex-col border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.playBreakdown")}</CardTitle>
</CardHeader>
<CardContent className="pb-4">
{loading ? (
<Skeleton className="h-[220px] w-full" />
) : data ? (
<div className="max-h-[280px] overflow-y-auto pr-1">
<PlayBreakdownChart
rows={data.play_breakdown}
metric={metric}
formatMoney={formatMoneyMinor}
currency={currency}
playLabel={resolvePlayLabel}
/>
</div>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
</div>
{data && !loading ? (
<Card className="border-border/80 shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("analytics.periodDistribution")}</CardTitle>
</CardHeader>
<CardContent>
<PeriodCompareStrip
series={data.daily_series}
formatMoney={formatMoneyMinor}
currency={currency}
/>
</CardContent>
</Card>
) : null}
<DashboardAnalyticsMain analytics={analytics} />
<DashboardPlayRankingCard analytics={analytics} />
</section>
);
}

View File

@@ -2,6 +2,23 @@
import type { ReactElement } from "react";
export function DashboardChartEmpty({ message }: { message: string }): ReactElement {
return <p className="py-10 text-center text-sm text-muted-foreground">{message}</p>;
import { cn } from "@/lib/utils";
export function DashboardChartEmpty({
message,
compact = false,
}: {
message: string;
compact?: boolean;
}): ReactElement {
return (
<p
className={cn(
"text-center text-muted-foreground",
compact ? "py-1 text-[11px] leading-snug" : "py-10 text-sm",
)}
>
{message}
</p>
);
}

View File

@@ -10,9 +10,12 @@ import {
FileSearch,
RefreshCw,
ScrollText,
Settings,
Shield,
Ticket,
Wallet,
BarChart3,
Scale,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
@@ -23,20 +26,25 @@ import {
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import {
DashboardAnalyticsMain,
DashboardPlayRankingCard,
} from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
AbnormalTransferPanelFooter,
CapUsageBar,
FinanceStructureChart,
HotUsageBars,
PayoutCompositionChart,
PayoutPanelSnapshot,
ResultBatchProgress,
StatCard,
DashboardPanelCard,
SettlementStatusChart,
SoldOutRing,
} from "@/modules/dashboard/dashboard-visuals";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
@@ -52,14 +60,6 @@ import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
type HotPlayTab = "4D" | "3D" | "2D" | "special";
type SoldOutBuckets = {
d4: number;
d3: number;
d2: number;
special: number;
other: number;
};
function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
@@ -76,6 +76,14 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
}
}
function drawScopedHref(
drawId: number | null,
suffix = "",
fallback = "/admin/draws",
): string {
return drawId != null ? `/admin/draws/${drawId}${suffix}` : fallback;
}
function poolPlayCategory(normalizedNumber: string): HotPlayTab | "other" {
const raw = normalizedNumber.trim();
const digits = raw.replace(/\D/g, "");
@@ -130,7 +138,6 @@ export function DashboardConsole(): ReactElement {
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
const [hotPoolSample, setHotPoolSample] = useState<AdminRiskPoolRow[]>([]);
const [soldOutBuckets, setSoldOutBuckets] = useState<SoldOutBuckets | null>(null);
const [abnormalTransferTotal, setAbnormalTransferTotal] = useState<number | null>(null);
const [hotTab, setHotTab] = useState<HotPlayTab>("4D");
const [playOptions, setPlayOptions] = useState<{ code: string; label: string }[]>([]);
@@ -171,7 +178,6 @@ export function DashboardConsole(): ReactElement {
setRiskLocked(0);
setRiskCap(0);
setHotPoolSample([]);
setSoldOutBuckets(null);
setAbnormalTransferTotal(null);
try {
@@ -194,7 +200,6 @@ export function DashboardConsole(): ReactElement {
setRiskLocked(d.risk.locked_amount);
setRiskCap(d.risk.cap_amount);
setHotPoolSample(d.risk.hot_pool_rows);
setSoldOutBuckets(d.risk.sold_out_buckets);
}
setAbnormalTransferTotal(d.abnormal_transfer_total);
} catch (e) {
@@ -220,41 +225,44 @@ export function DashboardConsole(): ReactElement {
const hotRows = useMemo(() => topPoolsForTab(hotPoolSample, hotTab), [hotPoolSample, hotTab]);
const hallStatusLabel = hall?.status ?? "—";
const isOpenLike =
hallStatusLabel.toLowerCase().includes("open") ||
hallStatusLabel.toLowerCase().includes("sale");
const analytics = useDashboardAnalytics({ enabled: canFinance, playOptions });
const showAnalytics = canFinance;
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-5" /> },
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-5" /> },
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-4" /> },
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-4" /> },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: t("quickLinks.results"),
icon: <FileSearch className="size-5" />,
icon: <FileSearch className="size-4" />,
},
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-5" /> },
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-5" /> },
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-5" /> },
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-4" /> },
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-4" /> },
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-4" /> },
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <BarChart3 className="size-4" /> },
{ href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: <Scale className="size-4" /> },
{ href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: <Shield className="size-4" /> },
{ href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: <Settings className="size-4" /> },
];
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold tracking-tight text-foreground">{t("title")}</h1>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{todayLabel}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={loading || refreshing}
onClick={() => void load(true)}
>
<RefreshCw className={refreshing ? "size-4 animate-spin" : "size-4"} />
{t("actions.refresh", { ns: "common" })}
</Button>
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="admin-list-title">{t("title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">{todayLabel}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={loading || refreshing}
onClick={() => void load(true)}
>
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
{error ? (
@@ -271,302 +279,267 @@ export function DashboardConsole(): ReactElement {
</Alert>
) : null}
{!loading && hall ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/80 bg-card px-4 py-3 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<Ticket className="size-5 text-primary" aria-hidden />
<div>
<p className="text-xs text-muted-foreground">{t("sections.currentDraw")}</p>
<p className="font-mono text-lg font-semibold text-foreground">{hall.draw_no}</p>
</div>
<span className="text-sm text-muted-foreground">
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
</span>
<span className="inline-flex items-center gap-1.5 text-sm">
<span
className={cn(
"size-1.5 rounded-full",
isOpenLike ? "bg-emerald-500" : "bg-muted-foreground",
)}
/>
{hallStatusLabel}
</span>
</div>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "text-xs")}
>
{t("drawFinanceDetails")}
</Link>
) : null}
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<StatCard
label={t("pendingReviewResults")}
<section className="flex min-w-0 flex-col gap-4">
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${hall?.seconds_to_close ?? 0}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<DashboardPanelCard
href={drawScopedHref(drawId, "/review")}
title={t("pendingReviewResults")}
value={pendingReview ?? "—"}
hint={t("resultBatches")}
subtitle={t("resultBatches")}
actionLabel={
(pendingReview ?? 0) > 0
? t("actions.reviewNow", { ns: "common" })
: t("drawDetails")
}
icon={<ClipboardList className="size-5" aria-hidden />}
accent={(pendingReview ?? 0) > 0 ? "destructive" : "muted"}
/>
<StatCard
label={t("abnormalTransferOrders")}
highlight={(pendingReview ?? 0) > 0}
loading={loading}
>
{drawPanel ? <ResultBatchProgress draw={drawPanel} compact /> : null}
</DashboardPanelCard>
<DashboardPanelCard
href="/admin/wallet/transfer-orders"
title={t("abnormalTransferOrders")}
value={abnormalTransferTotal ?? "—"}
hint={t("viewTransferOrders")}
subtitle={t("abnormalTransferScope")}
actionLabel={t("actions.viewAll", { ns: "common" })}
icon={<AlertTriangle className="size-5" aria-hidden />}
accent={(abnormalTransferTotal ?? 0) > 0 ? "destructive" : "muted"}
/>
<StatCard
label={t("riskCapUsage")}
accent={(abnormalTransferTotal ?? 0) > 0 ? "warning" : "muted"}
loading={loading}
highlight={(abnormalTransferTotal ?? 0) > 0}
>
<AbnormalTransferPanelFooter
total={abnormalTransferTotal}
walletPermission={capabilities?.wallet_transfer_view ?? true}
/>
</DashboardPanelCard>
<DashboardPanelCard
href={drawScopedHref(drawId, "/risk/occupancy", "/admin/risk")}
title={t("riskCapUsage")}
value={`${usagePct.toFixed(1)}%`}
hint={t("lockedAndCap", { locked: formatMoneyMinor(riskLocked, currency), cap: formatMoneyMinor(riskCap, currency) })}
subtitle={t("lockedAndCap", {
locked: formatMoneyMinor(riskLocked, currency),
cap: formatMoneyMinor(riskCap, currency),
})}
actionLabel={t("occupancyDetails")}
icon={<Shield className="size-5" aria-hidden />}
accent={usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"}
/>
<StatCard
label={t("sections.currentDraw")}
value={hall?.draw_no ?? "—"}
hint={t("drawSequence", { sequence: hall?.sequence_no ?? "—" })}
icon={<Ticket className="size-5" aria-hidden />}
accent={
usagePct >= 90 ? "destructive" : usagePct >= 70 ? "primary" : "muted"
}
loading={loading}
>
<CapUsageBar
locked={riskLocked}
cap={riskCap}
usagePct={usagePct}
formatMoney={formatMoneyMinor}
currency={currency}
compact
/>
</DashboardPanelCard>
<DashboardPanelCard
href={drawScopedHref(drawId, "/finance")}
title={t("payoutComposition")}
value={
finance
? formatMoneyMinor(finance.total_payout_minor, currency)
: "—"
}
subtitle={
finance
? t("orderAndTicket", {
orders: finance.order_count,
tickets: finance.ticket_item_count,
})
: t("states.noData", { ns: "common" })
}
actionLabel={t("detailsShort")}
icon={<Wallet className="size-5" aria-hidden />}
accent="primary"
/>
</div>
loading={loading}
>
{finance ? (
<PayoutPanelSnapshot finance={finance} formatMoney={formatMoneyMinor} />
) : null}
</DashboardPanelCard>
</div>
</section>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<Card className="border-border/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("riskCapUsage")}</CardTitle>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/risk/occupancy`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
>
{t("occupancyDetails")}
</Link>
) : null}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-44 w-full" />
) : (
<CapUsageBar
locked={riskLocked}
cap={riskCap}
usagePct={usagePct}
formatMoney={formatMoneyMinor}
currency={currency}
/>
)}
</CardContent>
</Card>
<section
className={cn(
"grid min-w-0 grid-cols-1 gap-4",
showAnalytics ? "xl:grid-cols-12" : "xl:grid-cols-1",
)}
>
<div
className={cn(
"flex min-w-0 flex-col gap-4",
showAnalytics ? "xl:col-span-8" : "xl:col-span-12",
)}
>
{showAnalytics ? <DashboardAnalyticsMain analytics={analytics} /> : null}
<Card className="border-border/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("soldOutDistribution")}</CardTitle>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/risk/sold-out`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-44 w-full" />
) : soldOutBuckets ? (
<SoldOutRing buckets={soldOutBuckets} />
) : (
<p className="py-10 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="border-border/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("resultBatches")}</CardTitle>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
>
{t("drawDetails")}
</Link>
) : null}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-44 w-full" />
) : drawPanel ? (
<ResultBatchProgress draw={drawPanel} />
) : (
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="border-border/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("payoutComposition")}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-44 w-full" />
) : finance ? (
<PayoutCompositionChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
</div>
<DashboardAnalyticsPanel enabled={canFinance} playOptions={playOptions} />
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<Card className="border-border/80 shadow-sm xl:col-span-1">
<CardHeader className="pb-2">
<CardTitle className="text-base">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-40 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="border-border/80 shadow-sm xl:col-span-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("settlementOverview")}</CardTitle>
{drawId != null ? (
<Link
href="/admin/settlement-batches"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-40 w-full" />
) : finance ? (
<SettlementStatusChart finance={finance} />
) : (
<p className="py-6 text-center text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
)}
</CardContent>
</Card>
<Card className="border-border/80 shadow-sm xl:col-span-1">
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-base">{t("hotNumbersTop10")}</CardTitle>
<div className="flex items-center gap-2">
<div role="tablist" aria-label={t("playDimension")} className="flex gap-1">
{([
{ value: "4D", label: t("tabs.4d") },
{ value: "3D", label: t("tabs.3d") },
{ value: "2D", label: t("tabs.2d") },
{ value: "special", label: t("tabs.special") },
] as const).map((tab) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={hotTab === tab.value}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
hotTab === tab.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
onClick={() => setHotTab(tab.value)}
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
<Card className="admin-list-card py-0">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("settlementOverview")}</CardTitle>
{drawId != null ? (
<Link
href="/admin/settlement-batches"
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
>
{tab.label}
</button>
))}
</div>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/risk/hot`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 px-2 text-xs")}
>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</div>
</CardHeader>
<CardContent>{loading ? <Skeleton className="h-64 w-full" /> : <HotUsageBars rows={hotRows} />}</CardContent>
</Card>
</div>
{t("actions.viewAll", { ns: "common" })}
</Link>
) : null}
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-40 w-full" />
) : finance ? (
<SettlementStatusChart finance={finance} />
) : (
<p className="py-10 text-center text-xs text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
)}
</CardContent>
</Card>
<h2 className="text-sm font-semibold tracking-wide text-muted-foreground">{t("sections.operations")}</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-sm">
<ClipboardList className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t("pendingReviewResults")}</p>
<p className="mt-1 text-4xl font-bold tabular-nums text-destructive">{pendingReview ?? "—"}</p>
</div>
<Card className="admin-list-card py-0">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 border-b border-border/60 px-4 py-3">
<CardTitle className="text-sm font-semibold">{t("hotNumbersTop10")}</CardTitle>
<div className="flex flex-wrap items-center gap-1">
<div role="tablist" aria-label={t("playDimension")} className="flex gap-0.5">
{([
{ value: "4D", label: t("tabs.4d") },
{ value: "3D", label: t("tabs.3d") },
{ value: "2D", label: t("tabs.2d") },
{ value: "special", label: t("tabs.special") },
] as const).map((tab) => (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={hotTab === tab.value}
className={cn(
"rounded px-2 py-0.5 text-[11px] font-medium transition-colors",
hotTab === tab.value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
onClick={() => setHotTab(tab.value)}
>
{tab.label}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-48 w-full" />
) : (
<HotUsageBars rows={hotRows} compact />
)}
</CardContent>
</Card>
</div>
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/review`}
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
>
{t("actions.reviewNow", { ns: "common" })}
</Link>
{!showAnalytics ? (
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2">
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-52 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<p className="py-10 text-center text-xs text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
)}
</CardContent>
</Card>
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
{quickLinks.map((q) => (
<Link
key={q.href + q.label}
href={q.href}
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
{q.icon}
</span>
<span className="line-clamp-2">{q.label}</span>
</Link>
))}
</CardContent>
</Card>
</div>
) : null}
</div>
<div className="flex flex-col justify-between gap-4 rounded-xl border border-border/80 bg-card p-5 shadow-sm sm:flex-row sm:items-center">
<div className="flex gap-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-amber-500 text-white shadow-sm">
<AlertTriangle className="size-5" aria-hidden />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{t("abnormalTransferOrders")}</p>
<p className="mt-1 text-4xl font-bold tabular-nums text-amber-600">{abnormalTransferTotal ?? "—"}</p>
</div>
</div>
<Link
href="/admin/wallet/transfer-orders"
className={cn(buttonVariants({ variant: "outline" }), "shrink-0")}
>
{t("viewTransferOrders")}
</Link>
</div>
</div>
<div className="grid gap-4">
<Card className="border-border/80 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap justify-center gap-3 py-2 sm:gap-5">
{quickLinks.map((q) => (
<Link
key={q.label}
href={q.href}
className="flex w-24 flex-col items-center gap-2 rounded-lg border border-transparent p-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border hover:bg-muted/50"
>
<span className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm">
{q.icon}
</span>
{q.label}
</Link>
))}
</CardContent>
</Card>
</div>
{showAnalytics ? (
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
<DashboardPlayRankingCard analytics={analytics} />
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
</CardHeader>
<CardContent className="px-4 py-4">
{loading ? (
<Skeleton className="h-52 w-full" />
) : finance ? (
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
) : (
<p className="py-10 text-center text-xs text-muted-foreground">
{t("states.noData", { ns: "common" })}
</p>
)}
</CardContent>
</Card>
<Card className="admin-list-card py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
{quickLinks.map((q) => (
<Link
key={q.href + q.label}
href={q.href}
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
{q.icon}
</span>
<span className="line-clamp-2">{q.label}</span>
</Link>
))}
</CardContent>
</Card>
</aside>
) : null}
</section>
</div>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import Link from "next/link";
import type { ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { ArrowRight, Clock, Ticket } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
function isOpenLikeStatus(status: string): boolean {
const lower = status.toLowerCase();
return lower.includes("open") || lower.includes("sale");
}
type DashboardCurrentDrawCardProps = {
hall: DrawCurrentSnapshot | null;
drawId: number | null;
loading?: boolean;
};
export function DashboardCurrentDrawCard({
hall,
drawId,
loading = false,
}: DashboardCurrentDrawCardProps): ReactElement {
const { t } = useTranslation("dashboard");
const formatDt = useAdminDateTimeFormatter();
if (loading) {
return (
<Card className="admin-list-card overflow-hidden py-0">
<CardContent className="p-5">
<Skeleton className="h-[4.5rem] w-full rounded-xl" />
</CardContent>
</Card>
);
}
if (!hall) {
return (
<Card className="admin-list-card overflow-hidden py-0">
<CardContent className="flex min-h-[5.5rem] flex-col items-center justify-center gap-2 p-6 text-center">
<Ticket className="size-9 text-muted-foreground/40" aria-hidden />
<p className="text-sm font-medium text-muted-foreground">{t("sections.currentDraw")}</p>
<p className="text-xs text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
</CardContent>
</Card>
);
}
const openLike = isOpenLikeStatus(hall.status);
return (
<Card className="admin-list-card overflow-hidden border-primary/15 py-0 shadow-sm">
<CardContent className="relative p-0">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/[0.07] via-transparent to-transparent"
/>
<div className="relative flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex min-w-0 items-center gap-4">
<div
className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm"
aria-hidden
>
<Ticket className="size-5" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("sections.currentDraw")}
</p>
<p className="mt-1 font-mono text-2xl font-bold leading-none tracking-tight text-foreground sm:text-[1.65rem]">
{hall.draw_no}
</p>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{t("drawSequence", { sequence: hall.sequence_no ?? "—" })}
</span>
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
openLike
? "bg-emerald-500/12 text-emerald-700 ring-1 ring-emerald-500/20 dark:text-emerald-400"
: "bg-muted/80 text-muted-foreground ring-1 ring-border/60",
)}
>
<span
className={cn(
"size-1.5 rounded-full",
openLike ? "bg-emerald-500" : "bg-muted-foreground/70",
)}
/>
{hall.status}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 sm:justify-end">
{hall.draw_time ? (
<p className="inline-flex items-center gap-1.5 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground ring-1 ring-border/50">
<Clock className="size-3.5 shrink-0" aria-hidden />
<span>{t("scheduledDrawTime", { time: formatDt(hall.draw_time) })}</span>
</p>
) : null}
{drawId != null ? (
<Link
href={`/admin/draws/${drawId}/finance`}
className={cn(
buttonVariants({ variant: "default", size: "sm" }),
"h-9 gap-1.5 px-4 shadow-sm",
)}
>
{t("drawFinanceDetails")}
<ArrowRight className="size-3.5" aria-hidden />
</Link>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -175,12 +175,14 @@ export function PlayBreakdownChart({
formatMoney,
currency,
playLabel,
compact = false,
}: {
rows: AdminDashboardAnalyticsPlayRow[];
metric: DashboardAnalyticsMetric;
formatMoney: MoneyFormatter;
currency: string | null;
playLabel: (code: string, dimension: number) => string;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const activeMetric = metric === "overview" ? "bet" : metric;
@@ -216,19 +218,21 @@ export function PlayBreakdownChart({
return <DashboardChartEmpty message={t("analytics.noPlayData")} />;
}
const chartHeight = Math.min(480, Math.max(180, rows.length * 36 + 48));
const chartHeight = compact
? Math.max(160, rows.length * 32 + 24)
: Math.min(480, Math.max(180, rows.length * 36 + 48));
return (
<ChartContainer
config={chartConfig}
className="aspect-auto w-full"
className="aspect-auto w-full min-w-0"
style={{ height: chartHeight }}
>
<BarChart
accessibilityLayer
layout="vertical"
data={chartData}
margin={{ top: 4, right: 16, bottom: 4, left: 4 }}
margin={{ top: 4, right: 8, bottom: 4, left: 0 }}
>
<XAxis type="number" hide />
<ChartTooltip
@@ -255,7 +259,7 @@ export function PlayBreakdownChart({
/>
}
/>
<Bar dataKey="value" radius={4} barSize={14}>
<Bar dataKey="value" radius={4} barSize={compact ? 12 : 14}>
{chartData.map((entry) => (
<Cell key={entry.id} fill={entry.fill} />
))}
@@ -263,10 +267,13 @@ export function PlayBreakdownChart({
<YAxis
type="category"
dataKey="label"
width={100}
width={compact ? 76 : 100}
tickLine={false}
axisLine={false}
tick={{ fontSize: 11 }}
tick={{ fontSize: 10 }}
tickFormatter={(value) =>
typeof value === "string" && value.length > 10 ? `${value.slice(0, 10)}` : String(value)
}
/>
</BarChart>
</ChartContainer>

View File

@@ -1,8 +1,10 @@
"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,
@@ -18,6 +20,7 @@ import {
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChartContainer,
ChartLegend,
@@ -87,18 +90,137 @@ function settlementBarColor(status: string): string {
}
}
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 (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
<div className="flex min-w-0 items-start gap-3">
<div
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg",
kpiAccentClass(accent),
)}
>
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground">
{value}
</p>
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}
</div>
</div>
{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="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
) : null}
</div>
);
}
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"
@@ -107,9 +229,15 @@ export function StatCard({
? "bg-muted text-foreground"
: "bg-primary text-primary-foreground";
return (
<Card className="border-border/80 py-0 shadow-sm">
<CardContent className="flex gap-4 p-5">
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",
@@ -118,14 +246,292 @@ export function StatCard({
>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">{value}</p>
{hint ? <div className="mt-2 text-xs text-muted-foreground">{hint}</div> : null}
<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" />
) : (
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{value}
</p>
)}
{subtitle && !loading ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{subtitle}
</p>
) : 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 (
<p className="rounded-lg bg-muted/40 px-3 py-2.5 text-center text-[11px] text-muted-foreground ring-1 ring-border/50">
{t("states.noData", { ns: "common" })}
</p>
);
}
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 = finance.total_bet_minor;
const win = finance.total_win_payout_minor;
const jackpot = finance.total_jackpot_win_minor;
const hasPayout = 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">
<div className="grid grid-cols-3 gap-2 text-center">
{cells.map((cell) => (
<div
key={cell.key}
className={cn(
"rounded-lg px-1.5 py-2 ring-1",
cell.emphasize
? "bg-primary/6 ring-primary/15"
: "bg-muted/30 ring-border/50",
)}
>
<p className="text-[10px] leading-tight text-muted-foreground">{cell.label}</p>
<p
className={cn(
"mt-1 text-[11px] font-bold tabular-nums leading-tight",
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
>
{formatMoney(cell.amount, currency)}
</p>
</div>
))}
</div>
{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({
@@ -134,12 +540,15 @@ export function CapUsageBar({
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));
@@ -150,6 +559,24 @@ export function CapUsageBar({
);
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
if (compact) {
return (
<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>
);
}
return (
<div className="space-y-4">
<ChartContainer
@@ -178,7 +605,9 @@ export function CapUsageBar({
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>
<tspan className="fill-foreground text-2xl font-bold">
{pct.toFixed(1)}%
</tspan>
</text>
);
}}
@@ -240,7 +669,7 @@ export function FinanceStructureChart({
<YAxis type="category" dataKey="segment" hide width={0} />
<ChartTooltip
content={
<ChartTooltipContent formatter={(value, _name) => formatMoney(Number(value), currency)} />
<ChartTooltipContent formatter={(value) => formatMoney(Number(value), currency)} />
}
/>
<Bar dataKey="win" stackId="structure" fill="var(--color-win)" radius={4} />
@@ -259,9 +688,11 @@ export function FinanceStructureChart({
export function PayoutCompositionChart({
finance,
formatMoney,
compact = false,
}: {
finance: AdminDrawFinanceSummaryData;
formatMoney: MoneyFormatter;
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const currency = finance.currency_code;
@@ -279,7 +710,7 @@ export function PayoutCompositionChart({
);
if (total <= 0) {
return <DashboardChartEmpty message={t("noPayoutYet")} />;
return <DashboardChartEmpty message={t("noPayoutYet")} compact={compact} />;
}
const pieData = [
@@ -288,7 +719,13 @@ export function PayoutCompositionChart({
];
return (
<ChartContainer config={chartConfig} className="mx-auto aspect-square h-[220px] w-full max-w-[280px]">
<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={
@@ -310,13 +747,21 @@ export function PayoutCompositionChart({
<Cell key={entry.key} fill={entry.fill} />
))}
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
{compact ? null : (
<ChartLegend content={<ChartLegendContent nameKey="key" />} />
)}
</PieChart>
</ChartContainer>
);
}
export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactElement {
export function HotUsageBars({
rows,
compact = false,
}: {
rows: AdminRiskPoolRow[];
compact?: boolean;
}): ReactElement {
const { t } = useTranslation("dashboard");
const chartConfig = useMemo(() => buildUsageBarConfig(t("riskCapUsage")), [t]);
@@ -337,7 +782,9 @@ export function HotUsageBars({ rows }: { rows: AdminRiskPoolRow[] }): ReactEleme
return <DashboardChartEmpty message={t("noPoolData")} />;
}
const chartHeight = Math.min(420, Math.max(160, rows.length * 32 + 48));
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
@@ -445,7 +892,13 @@ export function SoldOutRing({ buckets }: { buckets: SoldOutBuckets }): ReactElem
);
}
export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel }): ReactElement {
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);
@@ -462,6 +915,43 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
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">
@@ -478,20 +968,7 @@ export function ResultBatchProgress({ draw }: { draw: AdminDashboardDrawPanel })
<Bar dataKey="other" stackId="batch" fill="var(--color-other)" radius={4} />
</BarChart>
</ChartContainer>
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
<p className="text-2xl font-bold tabular-nums text-amber-600">{pending_review}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("batchPending")}</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
<p className="text-2xl font-bold tabular-nums text-emerald-600">{published}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("batchPublished")}</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-3">
<p className="text-2xl font-bold tabular-nums">{total}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("batchTotal")}</p>
</div>
</div>
{statCells}
</div>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { format, subDays } from "date-fns";
import { useTranslation } from "react-i18next";
import { getAdminDashboardAnalytics } from "@/api/admin-dashboard";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardAnalyticsData,
DashboardAnalyticsMetric,
DashboardAnalyticsPeriod,
} from "@/types/api/admin-dashboard-analytics";
export const DASHBOARD_ANALYTICS_PERIODS: DashboardAnalyticsPeriod[] = [
"today",
"last_7_days",
"last_30_days",
"this_month",
"lifetime",
"custom",
];
export const DASHBOARD_RANKING_METRICS: DashboardAnalyticsMetric[] = ["bet", "payout", "profit"];
export function formatDashboardMoneyMinor(minor: number, currencyCode: string | null): string {
const code = (currencyCode ?? "NPR").toUpperCase();
const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals;
try {
return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency",
currency: code,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(major);
} catch {
return formatAdminMinorUnits(minor, code, decimals);
}
}
export function formatDashboardSignedMoneyMinor(minor: number, currencyCode: string | null): string {
if (minor === 0) {
return formatDashboardMoneyMinor(0, currencyCode);
}
const s = minor > 0 ? "+" : "";
return `${s}${formatDashboardMoneyMinor(Math.abs(minor), currencyCode)}`;
}
export function useDashboardAnalytics({
enabled,
playOptions,
}: {
enabled: boolean;
playOptions: { code: string; label: string }[];
}) {
const { t } = useTranslation(["dashboard", "common"]);
const playLabel = useAdminPlayCodeLabel();
const [period, setPeriod] = useState<DashboardAnalyticsPeriod>("last_7_days");
const [rankingMetric, setRankingMetric] = useState<DashboardAnalyticsMetric>("bet");
const [playCode, setPlayCode] = useState<string>("");
const [customFrom, setCustomFrom] = useState(() => format(subDays(new Date(), 6), "yyyy-MM-dd"));
const [customTo, setCustomTo] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<AdminDashboardAnalyticsData | null>(null);
const load = useCallback(async () => {
if (!enabled) {
setLoading(false);
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const payload = await getAdminDashboardAnalytics({
period,
metric: "overview",
play_code: playCode !== "" ? playCode : undefined,
...(period === "custom" ? { date_from: customFrom, date_to: customTo } : {}),
});
setData(payload);
} catch (e) {
setData(null);
const raw = e instanceof LotteryApiBizError ? e.message : "";
const needsAuthSync =
raw.includes("admin.dashboard.analytics") || raw.includes("资源未配置");
setError(
needsAuthSync ? t("warnings.apiResourceMissing") : raw || t("warnings.loadFailed"),
);
} finally {
setLoading(false);
}
}, [enabled, period, playCode, customFrom, customTo, t]);
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
const currency = data?.currency_code ?? null;
const summary = data?.summary;
const periodRangeLabel = useMemo(() => {
if (!data) {
return null;
}
return data.date_from === data.date_to
? data.date_from
: `${data.date_from}${data.date_to}`;
}, [data]);
const playFilterLabel = useMemo(() => {
if (playCode === "") {
return t("analytics.allPlays");
}
return playOptions.find((p) => p.code === playCode)?.label ?? playCode;
}, [playCode, playOptions, t]);
const resolvePlayLabel = useCallback(
(code: string, dimension: number) => {
const base = playLabel(code);
return dimension > 0 ? `${base} · ${dimension}D` : base;
},
[playLabel],
);
const topPlayRows = useMemo(() => {
if (!data) {
return [];
}
const rows = [...data.play_breakdown];
rows.sort((a, b) => {
if (rankingMetric === "payout") {
return b.total_payout_minor - a.total_payout_minor;
}
if (rankingMetric === "profit") {
return b.approx_house_gross_minor - a.approx_house_gross_minor;
}
return b.total_bet_minor - a.total_bet_minor;
});
return rows.slice(0, 5);
}, [data, rankingMetric]);
const sparklines = useMemo(() => {
const series = data?.daily_series ?? [];
return {
bet: series.map((d) => d.total_bet_minor),
payout: series.map((d) => d.total_payout_minor),
profit: series.map((d) => d.approx_house_gross_minor),
};
}, [data?.daily_series]);
return {
enabled,
period,
setPeriod,
rankingMetric,
setRankingMetric,
playCode,
setPlayCode,
customFrom,
setCustomFrom,
customTo,
setCustomTo,
loading,
error,
data,
currency,
summary,
periodRangeLabel,
playFilterLabel,
playOptions,
resolvePlayLabel,
topPlayRows,
sparklines,
formatMoney: formatDashboardMoneyMinor,
formatSignedMoney: formatDashboardSignedMoneyMinor,
t,
};
}
export type DashboardAnalyticsState = ReturnType<typeof useDashboardAnalytics>;