feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user