feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics
- 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复
- 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset)
- 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
2026-06-26 14:39:22 +08:00
parent 7d075ceb62
commit 0d984b00f0
126 changed files with 7423 additions and 7084 deletions

View File

@@ -2,16 +2,23 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
import {
BarChart3,
ClipboardList,
Network,
RefreshCw,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
@@ -22,20 +29,20 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import {
DashboardKpiCard,
DashboardQuickLinksCard,
DashboardReportsTeaser,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
import {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
function buildTodayBetHint(
@@ -52,6 +59,13 @@ function buildTodayBetHint(
return `${dateHint} · ${t("agent.noBetToday")}`;
}
function agentLineHref(agentNodeId: number | null | undefined): string {
if (agentNodeId != null && agentNodeId > 0) {
return `/admin/agents?agent_node_id=${agentNodeId}`;
}
return "/admin/agents";
}
export function AgentDashboardConsole(): ReactElement {
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
const tRef = useTranslationRef(["dashboard", "common"]);
@@ -69,25 +83,33 @@ export function AgentDashboardConsole(): ReactElement {
}, [i18n.language, i18n.resolvedLanguage, t]);
useAdminCurrencyCatalog();
const playOptions = useCachedPlayTypeOptions();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
const analyticsScope = useMemo(
() => ({
siteCode: agent?.site_code ?? "",
agentNodeId: agent?.id,
}),
[agent?.id, agent?.site_code],
);
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const lineHref = agentLineHref(agent?.id);
const quickLinks = useMemo(
() => [
{ href: "/admin/tickets", label: t("agent.quickLinks.tickets"), icon: ClipboardList },
{ href: lineHref, label: t("agent.quickLinks.players"), icon: Users },
{ href: lineHref, label: t("agent.quickLinks.agents"), icon: Network },
{
href: settlementCenterScopeHref(undefined, "awaiting_payment"),
label: t("agent.quickLinks.bills"),
icon: Wallet,
},
...(canReports
? [{ href: "/admin/reports", label: t("agent.quickLinks.reports"), icon: BarChart3 }]
: []),
],
[canReports, lineHref, t],
);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -99,14 +121,8 @@ export function AgentDashboardConsole(): ReactElement {
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.agent_overview);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
@@ -175,6 +191,7 @@ export function AgentDashboardConsole(): ReactElement {
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />}
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
href="/admin/tickets"
/>
<DashboardKpiCard
label={t("agent.todayShareProfit")}
@@ -184,12 +201,14 @@ export function AgentDashboardConsole(): ReactElement {
hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}`}
href={canReports ? "/admin/reports" : undefined}
/>
<DashboardKpiCard
label={t("agent.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
href={lineHref}
/>
<DashboardKpiCard
label={t("agent.pendingBills")}
@@ -199,6 +218,7 @@ export function AgentDashboardConsole(): ReactElement {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
href={settlementCenterScopeHref(undefined, "awaiting_payment")}
/>
</div>
@@ -289,6 +309,8 @@ export function AgentDashboardConsole(): ReactElement {
</CardContent>
</Card>
</div>
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
@@ -296,22 +318,14 @@ export function AgentDashboardConsole(): ReactElement {
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
<DashboardReportsTeaser
title={t("analytics.title")}
description={t("agent.shareProfitScopeHint")}
/>
) : !loading ? (
<p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
) : null}
</div>
);
}
}

View File

@@ -38,7 +38,6 @@ import {
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
AdminDashboardDrawPanel,
AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue,
@@ -172,7 +171,6 @@ export function DashboardConsole(): ReactElement {
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [drawPanel, setDrawPanel] = useState<AdminDashboardDrawPanel | null>(null);
const [finance, setFinance] = useState<AdminDrawFinanceSummaryData | null>(null);
const [capabilities, setCapabilities] = useState<{ draw_finance_risk: boolean; wallet_transfer_view: boolean } | null>(null);
const [resultBatchQueue, setResultBatchQueue] = useState<AdminDashboardResultBatchQueue | null>(
@@ -201,7 +199,6 @@ export function DashboardConsole(): ReactElement {
setError(null);
setFinance(null);
setCapabilities(null);
setDrawPanel(null);
setResultBatchQueue(null);
setLifetimeFinance(null);
setTodayFinance(null);
@@ -230,9 +227,6 @@ export function DashboardConsole(): ReactElement {
setTodayFinance(d.today_finance);
setApiWarnings(d.warnings ?? []);
setPlatformRisk(d.platform_risk);
if (d.draw != null) {
setDrawPanel(d.draw);
}
if (d.risk != null) {
setRiskLocked(d.risk.locked_amount);
setRiskCap(d.risk.cap_amount);
@@ -544,6 +538,23 @@ export function DashboardConsole(): ReactElement {
</Card>
</div>
{showAnalytics ? (
<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} />
) : (
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
) : null}
{!showAnalytics ? (
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
@@ -565,22 +576,7 @@ export function DashboardConsole(): ReactElement {
{showAnalytics ? (
<aside className="flex min-w-0 flex-col gap-4 xl:col-span-4">
<DashboardPlayRankingCard analytics={analytics} />
<DashboardAgentRankingCard 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} />
) : (
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
)}
</CardContent>
</Card>
<DashboardAgentRankingCard analytics={analytics} />
</aside>
) : null}
</section>

View File

@@ -4,7 +4,13 @@ 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 {
AlertTriangle,
ArrowRightIcon,
CheckCircle2,
ChevronRightIcon,
type LucideIcon,
} from "lucide-react";
import {
Bar,
BarChart,
@@ -19,7 +25,7 @@ import {
YAxis,
} from "recharts";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { Skeleton } from "@/components/ui/skeleton";
@@ -268,6 +274,7 @@ export function DashboardKpiCard({
currencyCode,
sparklineValues,
deltaLabel,
href,
}: {
label: string;
value?: ReactNode;
@@ -281,6 +288,8 @@ export function DashboardKpiCard({
currencyCode?: string | null;
sparklineValues?: number[];
deltaLabel?: ReactNode;
/** 整张卡片可点击跳转 */
href?: string;
}): ReactElement {
const resolvedValue =
typeof signedAmountMinor === "number" && currencyCode !== undefined
@@ -295,17 +304,30 @@ export function DashboardKpiCard({
? String(resolvedValue)
: undefined;
return (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
const card = (
<div
className={cn(
"flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4",
href && "transition-colors group-hover/kpi:border-primary/30 group-hover/kpi:shadow-sm",
)}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent),
)}
>
{icon}
<div className="flex shrink-0 items-center gap-1">
<div
className={cn(
"flex size-9 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent),
)}
>
{icon}
</div>
{href ? (
<ChevronRightIcon
className="size-4 text-muted-foreground/50 transition group-hover/kpi:text-primary"
aria-hidden
/>
) : null}
</div>
</div>
<p
@@ -336,10 +358,126 @@ export function DashboardKpiCard({
</div>
) : null}
{hint ? (
<p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
<p
className={cn(
"mt-2 text-[11px] leading-snug",
href ? "text-primary/80 group-hover/kpi:underline" : "text-muted-foreground",
)}
>
{hint}
</p>
) : null}
</div>
);
if (!href) {
return card;
}
return (
<Link
href={href}
className="group/kpi block h-full min-h-0 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{card}
</Link>
);
}
export type DashboardQuickLink = {
href: string;
label: string;
icon?: LucideIcon;
};
/** 仪表盘快捷入口:按钮或图标卡片两种布局 */
export function DashboardQuickLinksCard({
title,
links,
variant = "buttons",
}: {
title: string;
links: readonly DashboardQuickLink[];
variant?: "buttons" | "tiles";
}): ReactElement {
const { t } = useTranslation("dashboard");
if (links.length === 0) {
return <></>;
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
</CardHeader>
<CardContent
className={cn(
variant === "buttons" ? "flex flex-wrap gap-2" : "grid gap-3 sm:grid-cols-3",
)}
>
{links.map((link) => {
if (variant === "buttons") {
return (
<Link
key={link.href}
href={link.href}
className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground",
)}
>
{link.label}
</Link>
);
}
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className="flex flex-col gap-2 rounded-xl border bg-muted/20 px-4 py-4 transition-colors hover:bg-muted/40"
>
{Icon ? <Icon className="size-5 text-primary" aria-hidden /> : null}
<span className="text-sm font-medium">{link.label}</span>
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
</Link>
);
})}
</CardContent>
</Card>
);
}
/** 轻量报表入口:替代站点/代理仪表盘内嵌完整 analytics 面板 */
export function DashboardReportsTeaser({
title,
description,
href = "/admin/reports",
}: {
title: string;
description: string;
href?: string;
}): ReactElement {
const { t } = useTranslation("dashboard");
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<Link
href={href}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{t("viewReports")}
<ArrowRightIcon className="size-3.5" aria-hidden />
</Link>
</CardHeader>
<CardContent>
<p className="text-xs leading-relaxed text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function MiniSparkline({

View File

@@ -14,10 +14,7 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import {
DashboardKpiCard,
DashboardScopeMetric,
} from "@/modules/dashboard/dashboard-visuals";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type {
@@ -136,18 +133,21 @@ export function SiteCsDashboardConsole(): ReactElement {
value={overview.player_count}
icon={<Users className="size-4" />}
hint={t("cs.playerCountHint")}
href="/admin/players"
/>
<DashboardKpiCard
label={t("cs.ticketsToday")}
value={overview.ticket_order_count_today}
icon={<ClipboardList className="size-4" />}
hint={activityHint}
href="/admin/tickets"
/>
<DashboardKpiCard
label={t("cs.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Search className="size-4" />}
hint={t("cs.activePlayersHint")}
href="/admin/tickets"
/>
</div>
@@ -172,19 +172,6 @@ export function SiteCsDashboardConsole(): ReactElement {
})}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("cs.scopeTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<DashboardScopeMetric label={t("cs.playerCount")} value={String(overview.player_count)} />
<DashboardScopeMetric
label={t("cs.ticketsToday")}
value={String(overview.ticket_order_count_today)}
/>
</CardContent>
</Card>
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">

View File

@@ -2,15 +2,22 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
import {
BarChart3,
ClipboardList,
Network,
RefreshCw,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_DASHBOARD_ANALYTICS_ACCESS_ANY, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
@@ -20,19 +27,19 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import {
DashboardKpiCard,
DashboardQuickLinksCard,
DashboardReportsTeaser,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
import {
formatDashboardMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
function buildTodayBetHint(
@@ -65,26 +72,32 @@ export function SiteDashboardConsole(): ReactElement {
return formatAdminCalendarToday(locale, weekday);
}, [i18n.language, i18n.resolvedLanguage, t]);
const playOptions = useCachedPlayTypeOptions();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
const analyticsScope = useMemo(
() => ({
siteCode: site?.code ?? overview?.site_code ?? "",
agentNodeId: undefined,
}),
[overview?.site_code, site?.code],
);
const canReports = adminHasAnyPermission(permissions, [PRD_REPORT_VIEW]);
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const quickLinks = useMemo(
() => [
{ href: "/admin/tickets", label: t("site.quickLinks.tickets"), icon: ClipboardList },
{ href: "/admin/players", label: t("site.quickLinks.players"), icon: Users },
{ href: "/admin/agents", label: t("site.quickLinks.agents"), icon: Network },
{
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
label: t("site.quickLinks.bills"),
icon: Wallet,
},
...(canReports
? [{ href: "/admin/reports", label: t("site.quickLinks.reports"), icon: BarChart3 }]
: []),
],
[canReports, site?.id, t],
);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
@@ -95,14 +108,8 @@ export function SiteDashboardConsole(): ReactElement {
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_overview);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
@@ -171,6 +178,7 @@ export function SiteDashboardConsole(): ReactElement {
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />}
hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
href="/admin/tickets"
/>
<DashboardKpiCard
label={t("site.todayProfit")}
@@ -180,12 +188,14 @@ export function SiteDashboardConsole(): ReactElement {
hint={t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}
href={canReports ? "/admin/reports" : undefined}
/>
<DashboardKpiCard
label={t("site.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
href="/admin/players"
/>
<DashboardKpiCard
label={t("site.pendingBills")}
@@ -195,6 +205,7 @@ export function SiteDashboardConsole(): ReactElement {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
/>
</div>
@@ -245,6 +256,8 @@ export function SiteDashboardConsole(): ReactElement {
</CardContent>
</Card>
</div>
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
@@ -252,18 +265,10 @@ export function SiteDashboardConsole(): ReactElement {
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
<DashboardReportsTeaser
title={t("analytics.title")}
description={t("site.profitScopeHint")}
/>
) : null}
</div>

View File

@@ -13,13 +13,12 @@ import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import {
AbnormalTransferPanelFooter,
DashboardKpiCard,
DashboardScopeMetric,
DashboardStatRow,
DashboardQuickLinksCard,
} from "@/modules/dashboard/dashboard-visuals";
import { settlementCenterScopeHref } from "@/modules/settlement/settlement-center-nav";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
@@ -27,7 +26,6 @@ import type {
AdminDashboardSiteFinanceOverview,
AdminDashboardWarning,
} from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
export function SiteFinanceDashboardConsole(): ReactElement {
@@ -40,8 +38,6 @@ export function SiteFinanceDashboardConsole(): ReactElement {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteFinanceOverview | null>(null);
const [walletPermission, setWalletPermission] = useState(false);
@@ -55,15 +51,9 @@ export function SiteFinanceDashboardConsole(): ReactElement {
try {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_finance_overview);
setApiWarnings(d.warnings ?? []);
setWalletPermission(d.capabilities?.wallet_transfer_view ?? false);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
setDrawId(null);
}
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
@@ -80,15 +70,19 @@ export function SiteFinanceDashboardConsole(): ReactElement {
const displayCurrency = overview?.currency_code ?? "NPR";
const abnormalCount = overview?.abnormal_transfer_count ?? null;
const abnormalHref = "/admin/wallet/transfer-orders?abnormal=1";
const quickLinks = useMemo(
() => [
{ href: "/admin/reconcile", label: t("finance.quickLinks.reconcile") },
{ href: "/admin/wallet/transfer-orders", label: t("finance.quickLinks.transfers") },
{ href: "/admin/settlement-center", label: t("finance.quickLinks.bills") },
{
href: settlementCenterScopeHref(site?.id, "awaiting_payment"),
label: t("finance.quickLinks.bills"),
},
{ href: "/admin/reports", label: t("finance.quickLinks.reports") },
],
[t],
[site?.id, t],
);
return (
@@ -144,6 +138,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
icon={<AlertTriangle className="size-4" />}
hint={t("abnormalTransferScope")}
accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"}
href={abnormalHref}
/>
<DashboardKpiCard
label={t("finance.pendingConfirmBills")}
@@ -151,6 +146,7 @@ export function SiteFinanceDashboardConsole(): ReactElement {
icon={<ClipboardList className="size-4" />}
hint={t("finance.pendingConfirmHint")}
accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"}
href={settlementCenterScopeHref(site?.id, "pending_confirm")}
/>
<DashboardKpiCard
label={t("finance.payableBills")}
@@ -160,85 +156,43 @@ export function SiteFinanceDashboardConsole(): ReactElement {
amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency),
})}
accent={overview.payable_bill_count > 0 ? "destructive" : "muted"}
href={settlementCenterScopeHref(site?.id, "awaiting_payment")}
/>
<DashboardKpiCard
label={t("finance.walletPlayers")}
value={overview.wallet_player_count}
icon={<Users className="size-4" />}
hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })}
href="/admin/players"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("finance.settlementTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<DashboardStatRow
label={t("finance.pendingConfirmBills")}
value={String(overview.pending_confirm_bill_count)}
/>
<DashboardStatRow
label={t("finance.payableBills")}
value={String(overview.payable_bill_count)}
/>
<DashboardStatRow
label={t("finance.payableUnpaidLabel")}
value={formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency)}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<AbnormalTransferPanelFooter
total={abnormalCount}
walletPermission={walletPermission}
/>
<Link
href="/admin/wallet/transfer-orders?abnormal=1"
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
>
<Wallet className="size-3.5" />
{t("viewTransferOrders")}
</Link>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
<CardTitle className="text-sm font-semibold">{t("finance.reconcileTitle")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
{link.label}
</Link>
))}
<CardContent className="space-y-3">
<AbnormalTransferPanelFooter
total={abnormalCount}
walletPermission={walletPermission}
/>
<Link
href={abnormalHref}
className={buttonVariants({ variant: "outline", size: "sm", className: "w-full" })}
>
<Wallet className="size-3.5" />
{t("viewTransferOrders")}
</Link>
</CardContent>
</Card>
<DashboardQuickLinksCard title={t("quickLinksTitle")} links={quickLinks} variant="buttons" />
</section>
) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
{t("finance.overviewEmpty")}
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
</div>
);
}
}