refactor(admin-reports, i18n): remove rebate commission report and enhance localization

Removed the `getAdminReportRebateCommission` function and its references from the admin reports API and localization files. Updated CSS for improved money display handling in admin components. Enhanced localization support by adding new finance and support workspace entries in English, Nepali, and Chinese, improving user experience across the application.
This commit is contained in:
2026-06-16 16:04:03 +08:00
parent d4cf4ff436
commit a020e34a7d
38 changed files with 1259 additions and 353 deletions

View File

@@ -24,6 +24,8 @@ import { Button } from "@/components/ui/button";
import { percentValueToUi } from "@/lib/admin-rate-percent";
import { isLineRootAgentNode } from "@/lib/agent-profile-caps";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { cn } from "@/lib/utils";
import type { AgentNodeRow, AgentProfileRow } from "@/types/api/admin-agent";
@@ -369,10 +371,11 @@ function OverviewTab({
</p>
) : null}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.totalShareRate", { defaultValue: "占成比例" })}
value={profileLoading ? "…" : `${profile?.total_share_rate ?? 0}%`}
money={false}
subtitle={
parentRelativeShare
? t("profile.relativeShareRateValue", {
@@ -398,16 +401,18 @@ function OverviewTab({
/>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid min-w-0 grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
label={t("profile.rebateLimit", { defaultValue: "回水上限 (%)" })}
value={profileLoading ? "…" : `${rebateCap ?? "0"}%`}
money={false}
/>
<MetricCard
label={t("profile.defaultPlayerRebate", { defaultValue: "默认玩家回水 (%)" })}
value={
profileLoading ? "…" : `${percentValueToUi(profile?.default_player_rebate ?? 0)}%`
}
money={false}
/>
<MetricCard
label={t("profile.riskTags", { defaultValue: "风控标签" })}
@@ -418,6 +423,7 @@ function OverviewTab({
? profile!.risk_tags!.join(", ")
: t("common:states.none", { defaultValue: "无" })
}
money={false}
/>
<CapabilityMetric
label={t("profile.canGrantExtraRebate", { defaultValue: "允许额外回水" })}
@@ -567,11 +573,11 @@ function DownlineTable({
</div>
) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.credit_limit) : "—"}
<TableCell className={adminMoneyCellClassName("text-right text-xs")}>
{summary ? <AdminTableMoney>{formatCredit(summary.credit_limit)}</AdminTableMoney> : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.allocated_credit) : "—"}
<TableCell className={adminMoneyCellClassName("text-right text-xs")}>
{summary ? <AdminTableMoney>{formatCredit(summary.allocated_credit)}</AdminTableMoney> : "—"}
</TableCell>
<TableCell className="text-center tabular-nums text-xs">
{childCountById.get(child.id) ?? 0}
@@ -625,31 +631,45 @@ function MetricCard({
subtitle,
accent = false,
highlight = false,
money = true,
}: {
label: string;
value: string;
subtitle?: string;
accent?: boolean;
highlight?: boolean;
/** 金额类指标:自适应字号 + 换行 */
money?: boolean;
}): React.ReactElement {
return (
<div
className={cn(
"rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
"min-w-0 overflow-visible rounded-xl border bg-card px-4 py-4 shadow-sm transition-colors",
highlight && "border-primary/25 bg-primary/[0.04]",
accent && !highlight && "border-border/70",
!accent && !highlight && "border-border/70",
)}
>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p
className={cn(
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
highlight ? "text-primary" : "text-foreground",
)}
>
{value}
</p>
{money ? (
<AdminMoneyDisplay
as="p"
value={value}
size="lg"
className={cn("mt-1.5", highlight ? "text-primary" : "text-foreground")}
>
{value}
</AdminMoneyDisplay>
) : (
<p
className={cn(
"mt-1.5 text-2xl font-semibold tabular-nums tracking-tight",
highlight ? "text-primary" : "text-foreground",
)}
>
{value}
</p>
)}
{subtitle ? <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p> : null}
</div>
);

View File

@@ -19,6 +19,7 @@ import type { AgentParentCaps } from "@/types/api/admin-agent";
import { Info } from "lucide-react";
import { AdminNumericStepper } from "@/components/admin/admin-numeric-stepper";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import {
AGENT_PERCENT_HARD_MAX,
@@ -411,14 +412,14 @@ function ReadOnlyScalar({
<div
id={id}
className={cn(
"flex h-10 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 text-sm font-semibold tabular-nums text-foreground shadow-xs",
"flex min-h-10 min-w-0 items-center justify-center rounded-md border border-border/80 bg-muted/35 px-3 py-2 text-center shadow-xs",
className,
)}
>
<span>
<AdminMoneyDisplay as="span" value={value} size="sm" emphasize className="text-foreground">
{value}
{suffix ? <span className="ml-0.5 font-medium text-foreground/80">{suffix}</span> : null}
</span>
</AdminMoneyDisplay>
</div>
);
}

View File

@@ -17,6 +17,7 @@ import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendar
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -207,9 +208,14 @@ export function AgentDashboardConsole(): ReactElement {
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="break-all text-2xl font-semibold tabular-nums leading-tight">
<AdminMoneyDisplay
as="p"
value={formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
size="xl"
className="text-foreground"
>
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
</AdminMoneyDisplay>
<p className="mt-1 text-xs text-muted-foreground">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),

View File

@@ -2,13 +2,15 @@
import type { ReactElement } from "react";
import { isAgentOperator, isSiteAdminOperator } from "@/lib/admin-session-variants";
import { isAgentOperator, isSiteFinanceOperator, isSiteCsOperator, isSiteOperator } from "@/lib/admin-session-variants";
import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
import { SiteCsDashboardConsole } from "@/modules/dashboard/site-cs-dashboard-console";
import { SiteFinanceDashboardConsole } from "@/modules/dashboard/site-finance-dashboard-console";
import { SiteDashboardConsole } from "@/modules/dashboard/site-dashboard-console";
import { useAdminProfile } from "@/stores/admin-session";
/** 超管/平台账号走全站仪表盘;站点管理员走站点仪表盘;代理经营账号走代理仪表盘。 */
/** 超管/平台账号走全站仪表盘;站点运营账号走站点仪表盘;代理经营账号走代理仪表盘。 */
export function DashboardPageClient(): ReactElement {
const profile = useAdminProfile();
@@ -16,7 +18,15 @@ export function DashboardPageClient(): ReactElement {
return <AgentDashboardConsole />;
}
if (isSiteAdminOperator(profile)) {
if (isSiteFinanceOperator(profile)) {
return <SiteFinanceDashboardConsole />;
}
if (isSiteCsOperator(profile)) {
return <SiteCsDashboardConsole />;
}
if (isSiteOperator(profile)) {
return <SiteDashboardConsole />;
}

View File

@@ -21,6 +21,7 @@ import {
import { Card, CardContent } 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";
import {
ChartContainer,
@@ -37,6 +38,7 @@ import {
} from "@/lib/money";
import { cn } from "@/lib/utils";
import { SignedMoney, signedMoneyClass } from "@/lib/admin-signed-money";
import { adminMoneyDisplayClass } from "@/lib/admin-money-display";
import {
buildBatchProgressConfig,
buildFinanceStructureConfig,
@@ -68,7 +70,7 @@ type DashboardFinanceMetricCell = {
emphasize: boolean;
};
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时省略号 + hover 看全称 */
/** KPI 卡片底部三列:仅数字(币种见卡片主值),过长时缩小字号 + 换行 */
function formatDashboardMetricAmount(
minor: number,
currencyCode: string | null,
@@ -115,7 +117,8 @@ function DashboardFinanceMetricCells({
</p>
<p
className={cn(
"mt-1 truncate text-center text-[10px] font-bold tabular-nums leading-tight",
"mt-1 text-center font-bold tabular-nums leading-tight break-all",
adminMoneyDisplayClass(display, { size: "sm", emphasize: true }),
cell.emphasize ? "text-foreground" : "text-muted-foreground",
)}
title={title}
@@ -241,9 +244,14 @@ export function DashboardScopeMetric({
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 break-all text-base font-semibold tabular-nums leading-tight text-foreground">
<AdminMoneyDisplay
as="p"
value={value}
size="md"
className="mt-1 text-foreground"
>
{value}
</p>
</AdminMoneyDisplay>
</div>
);
}
@@ -303,8 +311,11 @@ export function DashboardKpiCard({
<p
title={valueTitle}
className={cn(
"mt-2 break-words text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
resolvedValueClassName ?? "text-foreground",
"mt-2 text-foreground",
resolvedValueClassName,
typeof resolvedValue === "string"
? adminMoneyDisplayClass(resolvedValue, { size: "lg", emphasize: true })
: "min-w-0 break-all text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
)}
>
{resolvedValue}
@@ -418,9 +429,21 @@ export function StatCard({
</div>
<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>
{typeof value === "string" || typeof value === "number" ? (
<AdminMoneyDisplay
as="p"
value={String(value)}
size="xl"
emphasize
className="mt-1 text-foreground"
>
{value}
</AdminMoneyDisplay>
) : (
<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}
@@ -557,6 +580,16 @@ export function DashboardPanelCard({
<p className="text-xs font-medium text-muted-foreground">{title}</p>
{loading ? (
<Skeleton className="mt-2 h-8 w-24 rounded-md" />
) : typeof value === "string" || typeof value === "number" ? (
<AdminMoneyDisplay
as="p"
value={String(value)}
size="xl"
emphasize
className="mt-1 text-foreground"
>
{value}
</AdminMoneyDisplay>
) : (
<p className="mt-1 text-2xl font-bold tabular-nums leading-none tracking-tight text-foreground">
{value}

View File

@@ -0,0 +1,196 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { ClipboardList, RefreshCw, Search, Users } 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 { 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 { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import {
DashboardKpiCard,
DashboardScopeMetric,
} from "@/modules/dashboard/dashboard-visuals";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import type {
AdminDashboardSiteCsOverview,
AdminDashboardWarning,
} from "@/types/api/admin-dashboard";
import { LotteryApiBizError } from "@/types/api/errors";
export function SiteCsDashboardConsole(): ReactElement {
const { t } = useTranslation(["dashboard", "common"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const site = profile?.site ?? null;
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [overview, setOverview] = useState<AdminDashboardSiteCsOverview | null>(null);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const d = await getAdminDashboard();
setOverview(d.site_cs_overview);
setApiWarnings(d.warnings ?? []);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load(false);
}, []);
const activityHint = useMemo(() => {
if (!overview) {
return "";
}
if (overview.latest_ticket_at) {
return t("cs.latestTicketAt", { time: formatDt(overview.latest_ticket_at) });
}
return t("cs.noTicketToday");
}, [formatDt, overview, t]);
const quickLinks = useMemo(
() => [
{ href: "/admin/players", label: t("cs.quickLinks.players"), icon: Users },
{ href: "/admin/tickets", label: t("cs.quickLinks.tickets"), icon: ClipboardList },
{ href: "/admin/wallet/transactions", label: t("cs.quickLinks.wallet"), icon: Search },
],
[t],
);
return (
<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("cs.title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
{site
? t("cs.subtitle", { name: site.name || site.code })
: t("cs.subtitleFallback")}
</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 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : overview ? (
<section className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
label={t("cs.playerCount")}
value={overview.player_count}
icon={<Users className="size-4" />}
hint={t("cs.playerCountHint")}
/>
<DashboardKpiCard
label={t("cs.ticketsToday")}
value={overview.ticket_order_count_today}
icon={<ClipboardList className="size-4" />}
hint={activityHint}
/>
<DashboardKpiCard
label={t("cs.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Search className="size-4" />}
hint={t("cs.activePlayersHint")}
/>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("cs.workspaceTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
{quickLinks.map((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 className="size-5 text-primary" aria-hidden />
<span className="text-sm font-medium">{link.label}</span>
<span className="text-xs text-muted-foreground">{t("cs.openModule")}</span>
</Link>
);
})}
</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">
{t("cs.overviewEmpty")}
</AdminNoResourceState>
)}
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ClipboardList, RefreshCw, Scale, Users, Wallet } from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref";
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 { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import {
AbnormalTransferPanelFooter,
DashboardKpiCard,
DashboardScopeMetric,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
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 {
const { t } = useTranslation(["dashboard", "common"]);
const tRef = useTranslationRef(["dashboard", "common"]);
const profile = useAdminProfile();
const site = profile?.site ?? null;
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<AdminDashboardSiteFinanceOverview | null>(null);
const [walletPermission, setWalletPermission] = useState(false);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
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");
setError(msg);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [tRef]);
useAsyncEffect(() => {
void load(false);
}, []);
const displayCurrency = overview?.currency_code ?? "NPR";
const abnormalCount = overview?.abnormal_transfer_count ?? null;
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: "/admin/reports", label: t("finance.quickLinks.reports") },
],
[t],
);
return (
<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("finance.title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
{site
? t("finance.subtitle", { name: site.name || site.code })
: t("finance.subtitleFallback")}
</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 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : overview ? (
<section className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard
label={t("finance.abnormalTransfers")}
value={abnormalCount ?? "—"}
icon={<AlertTriangle className="size-4" />}
hint={t("abnormalTransferScope")}
accent={(abnormalCount ?? 0) > 0 ? "destructive" : "muted"}
/>
<DashboardKpiCard
label={t("finance.pendingConfirmBills")}
value={overview.pending_confirm_bill_count}
icon={<ClipboardList className="size-4" />}
hint={t("finance.pendingConfirmHint")}
accent={overview.pending_confirm_bill_count > 0 ? "primary" : "muted"}
/>
<DashboardKpiCard
label={t("finance.payableBills")}
value={overview.payable_bill_count}
icon={<Scale className="size-4" />}
hint={t("finance.payableUnpaid", {
amount: formatDashboardMoneyMinor(overview.payable_unpaid_minor, displayCurrency),
})}
accent={overview.payable_bill_count > 0 ? "destructive" : "muted"}
/>
<DashboardKpiCard
label={t("finance.walletPlayers")}
value={overview.wallet_player_count}
icon={<Users className="size-4" />}
hint={t("finance.creditPlayersHint", { count: overview.credit_player_count })}
/>
</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>
</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>
</Card>
</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>
);
}

View File

@@ -49,6 +49,7 @@ import {
canDeleteDrawRow,
canEditDrawRow,
} from "./draw-list-actions";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { formatAdminMinorUnits } from "@/lib/money";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
@@ -429,25 +430,30 @@ export function DrawsIndexConsole() {
</TableCell>
{canViewFinance ? (
<>
<TableCell className="text-center text-xs tabular-nums">
{row.total_bet_minor != null
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
: "—"}
<TableCell className={adminMoneyCellClassName("text-center text-xs")}>
{row.total_bet_minor != null ? (
<AdminTableMoney>
{formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)}
</AdminTableMoney>
) : "—"}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{row.total_payout_minor != null
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
: "—"}
<TableCell className={adminMoneyCellClassName("text-center text-xs")}>
{row.total_payout_minor != null ? (
<AdminTableMoney>
{formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)}
</AdminTableMoney>
) : "—"}
</TableCell>
<TableCell
className={cn(
"text-center text-xs tabular-nums",
signedMoneyClass(row.profit_loss_minor ?? 0, true),
className={adminMoneyCellClassName(
cn("text-center text-xs", signedMoneyClass(row.profit_loss_minor ?? 0, true)),
)}
>
{row.profit_loss_minor != null
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"}
{row.profit_loss_minor != null ? (
<AdminTableMoney>
{formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)}
</AdminTableMoney>
) : "—"}
</TableCell>
</>
) : null}

View File

@@ -343,17 +343,21 @@ export function ReconcileConsole(): React.ReactElement {
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
<p className="text-sm text-muted-foreground">{t("createHint")}</p>
</CardHeader>
<CardContent className="admin-list-content pt-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
</div>
<div className="grid gap-1.5">
<CardContent className="admin-list-content">
<div className="admin-list-toolbar">
<div className="admin-list-field">
<span className="text-sm font-medium leading-none sm:shrink-0">{t("reconcileType")}</span>
<span className="inline-flex h-8 min-h-8 min-w-0 items-center rounded-md border border-border/60 bg-muted/30 px-2.5 text-sm text-foreground">
{t("reconcileTypeFixed")}
</span>
</div>
<div className="admin-list-field">
<Label htmlFor="rc-date-range" className="sm:shrink-0">
{t("dateRange")}
</Label>
<div className="min-w-0 w-full sm:w-60">
<AdminDateRangeField
id="rc-date-range"
label={t("dateRange")}
from={dateFrom}
to={dateTo}
onRangeChange={({ from, to }) => {
@@ -363,102 +367,101 @@ export function ReconcileConsole(): React.ReactElement {
/>
</div>
</div>
<div className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
<Input
id="rc-player-search"
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")}
/>
</div>
{selectedPlayer ? (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0 truncate font-medium text-foreground">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
{` · ${selectedPlayer.site_code}`}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedPlayer(null);
setPlayerSearch("");
setPlayerResults([]);
}}
>
{t("playerClear")}
</Button>
</div>
) : null}
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
<div className="rounded-lg border bg-background">
<div className="max-h-56 overflow-y-auto">
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
) : playerResults.length === 0 ? (
<AdminNoResourceState compact className="px-3 py-4" />
) : (
<div className="divide-y">
{playerResults.map((player) => {
const active = selectedPlayer?.id === player.id;
return (
<button
key={player.id}
type="button"
className={cn(
"flex w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30 font-medium",
)}
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
}}
>
<span className="min-w-0 truncate">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
{player.username ? ` · ${player.username}` : ""}
{` · ${player.site_code}`}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
) : null}
<div className="admin-list-field min-w-0 flex-1">
<Label htmlFor="rc-player-search" className="sm:shrink-0">
{t("playerSearch")}
</Label>
<Input
id="rc-player-search"
className="w-full sm:w-52"
value={playerSearch}
onChange={(e) => setPlayerSearch(e.target.value)}
placeholder={t("playerSearchPlaceholder")}
/>
</div>
<div className="admin-list-actions">
<Button
type="button"
className="w-full sm:w-auto"
disabled={submitting}
onClick={() =>
requestConfirm({
title: t("confirmCreateTitle"),
description: t("confirmCreateDescription", {
playerHint: selectedPlayer
? t("confirmCreatePlayer")
: t("confirmCreateAllPlayers"),
}),
onConfirm: () => onCreate(),
})
}
>
{submitting ? t("submitting") : t("createTask")}
</Button>
</div>
</div>
<div className="mt-4 flex justify-end">
<Button
type="button"
className="w-full sm:w-auto"
disabled={submitting}
onClick={() =>
requestConfirm({
title: t("confirmCreateTitle"),
description: t("confirmCreateDescription", {
playerHint: selectedPlayer
? t("confirmCreatePlayer")
: t("confirmCreateAllPlayers"),
}),
onConfirm: () => onCreate(),
})
}
>
{submitting ? t("submitting") : t("createTask")}
</Button>
</div>
{selectedPlayer ? (
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
<div className="min-w-0 truncate font-medium text-foreground">
{selectedPlayer.site_player_id}
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
{` · ${selectedPlayer.site_code}`}
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedPlayer(null);
setPlayerSearch("");
setPlayerResults([]);
}}
>
{t("playerClear")}
</Button>
</div>
) : null}
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
<div className="rounded-lg border bg-background">
<div className="max-h-56 overflow-y-auto">
{playerLoading ? (
<AdminLoadingInline className="py-2" label={t("loadingPlayers")} />
) : playerResults.length === 0 ? (
<AdminNoResourceState compact className="px-3 py-4" />
) : (
<div className="divide-y">
{playerResults.map((player) => {
const active = selectedPlayer?.id === player.id;
return (
<button
key={player.id}
type="button"
className={cn(
"flex w-full px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
active && "bg-muted/30 font-medium",
)}
onClick={() => {
setSelectedPlayer(player);
setPlayerSearch(player.site_player_id);
}}
>
<span className="min-w-0 truncate">
{player.site_player_id}
{player.nickname ? ` · ${player.nickname}` : ""}
{player.username ? ` · ${player.username}` : ""}
{` · ${player.site_code}`}
</span>
</button>
);
})}
</div>
)}
</div>
</div>
) : null}
</CardContent>
</Card>
) : (

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
CalendarDays,
CircleDollarSign,
Database,
FileDown,
FileSpreadsheet,
@@ -31,7 +30,6 @@ import {
getAdminReportDailyProfit,
getAdminReportPlayDimension,
getAdminReportPlayerWinLoss,
getAdminReportRebateCommission,
} from "@/api/admin-reports";
import {
buildReportJobParameters,
@@ -42,7 +40,13 @@ import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
import { getAdminUsers } from "@/api/admin-users";
import { getAdminTransferOrders } from "@/api/admin-wallet";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
import {
PRD_AUDIT_VIEW,
PRD_REPORT_EXPORT,
PRD_REPORT_VIEW,
PRD_RISK_ACCESS_ANY,
PRD_WALLET_TRANSFER_ACCESS_ANY,
} from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
@@ -73,6 +77,7 @@ import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-a
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money";
@@ -88,7 +93,6 @@ import type {
AdminReportDailyProfitRow,
AdminReportPlayDimensionRow,
AdminReportPlayerWinLossRow,
AdminReportRebateCommissionRow,
} from "@/types/api/admin-reports";
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
@@ -107,7 +111,6 @@ type ReportKey =
| "hot_number_risk"
| "play_dimension"
| "sold_out_number"
| "rebate_commission"
| "admin_audit";
type ReportDefinition = {
@@ -118,8 +121,22 @@ type ReportDefinition = {
scope: string;
fields: FieldKey[];
connected: boolean;
requiredAny: readonly string[];
};
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
const REPORTS: ReportDefinition[] = [
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY },
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true, requiredAny: [PRD_AUDIT_VIEW] },
];
type PreviewColumns = {
primary: string;
secondary: string;
@@ -159,7 +176,6 @@ type ReportResult =
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
| { key: "rebate_commission"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportRebateCommissionRow[] }
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
type StatCard = {
@@ -182,18 +198,6 @@ type PlayOption = {
label: string;
};
const REPORTS: ReportDefinition[] = [
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true },
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true },
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true },
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
{ key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
];
const emptyFilters: ReportFilters = {
drawNo: "",
drawId: null,
@@ -414,38 +418,6 @@ function buildPlayDimensionRowsAndSummary(
};
}
function buildRebateCommissionRowsAndSummary(
items: AdminReportRebateCommissionRow[],
total: number,
t: (key: string) => string,
pageScopedLabel: (statKey: string) => string,
currencyCode: string,
): Pick<Extract<ReportResult, { key: "rebate_commission" }>, "rows" | "summary"> {
let totalRebate = 0;
let totalOrders = 0;
const rows = items.map((item) => {
totalRebate += item.total_rebate_minor;
totalOrders += item.order_count;
return {
play_code: item.play_code,
total_rebate_minor: item.total_rebate_minor,
order_count: item.order_count,
ticket_item_count: item.ticket_item_count,
};
});
return {
rows,
summary: [
{ label: t("preview.stats.records"), value: String(total) },
{ label: t("preview.stats.currentPage"), value: String(items.length) },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, currencyCode) },
{ label: pageScopedLabel("orders"), value: String(totalOrders) },
],
};
}
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
return {
total: meta.total,
@@ -612,13 +584,6 @@ function defaultSummaryCards(
{ label: t("preview.stats.currency"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.usage"), value: t("preview.stats.notQueried") },
];
case "rebate_commission":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
{ label: t("fields.play"), value: filters.play || t("filterAll") },
{ label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") },
{ label: t("preview.stats.orders"), value: t("preview.stats.notQueried") },
];
case "admin_audit":
return [
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
@@ -641,14 +606,15 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const profile = useAdminProfile();
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
useAdminCurrencyCatalog();
useAdminPlayTypeCatalog();
const playCodeLabel = useAdminPlayCodeLabel();
const formatTs = useAdminDateTimeFormatter();
const filteredReports = useMemo(
() => (initialCategory ? REPORTS.filter((report) => report.category === initialCategory) : REPORTS),
[initialCategory],
);
const filteredReports = useMemo(() => {
const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny));
return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible;
}, [initialCategory, permissionSlugs]);
const [selectedKey, setSelectedKey] = useState<ReportKey>(
filteredReports[0]?.key ?? REPORTS[0].key,
);
@@ -758,17 +724,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
extra: t("preview.columns.soldOut.extra"),
time: t("preview.columns.soldOut.time"),
};
case "rebate_commission":
return {
primary: t("preview.columns.rebateCommission.primary"),
secondary: t("preview.columns.rebateCommission.secondary"),
metricA: t("preview.columns.rebateCommission.metricA"),
metricB: t("preview.columns.rebateCommission.metricB"),
metricC: t("preview.columns.rebateCommission.metricC"),
status: t("preview.columns.rebateCommission.status"),
extra: t("preview.columns.rebateCommission.extra"),
time: t("preview.columns.rebateCommission.time"),
};
case "admin_audit":
return {
primary: t("preview.columns.adminAudit.primary"),
@@ -1057,22 +1012,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
});
break;
}
case "rebate_commission": {
const payload = await getAdminReportRebateCommission(
reportListParams(filters, page, perPage),
);
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
setResult({
key: "rebate_commission",
raw: payload.items,
rows: next.rows,
meta: metaFromList(payload.meta),
summary: next.summary,
});
break;
}
case "admin_audit": {
const operatorId = filters.operatorId ?? parsePositiveInteger(filters.operator);
const payload = await getAdminAuditLogs({
@@ -1134,10 +1073,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
...prev,
drawNo: drawNoFromUrl || prev.drawNo,
}));
if (drawNoFromUrl) {
if (drawNoFromUrl && filteredReports.some((report) => report.key === "draw_profit")) {
setSelectedKey("draw_profit");
}
}, [drawNoFromUrl]);
}, [drawNoFromUrl, filteredReports]);
useEffect(() => {
queueMicrotask(() => {
@@ -1563,21 +1502,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
));
}
if (result.key === "rebate_commission") {
return result.raw.map((item) => (
<TableRow key={item.play_code}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.order_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, displayCurrency)}</TableCell>
<TableCell className="text-center">{item.ticket_item_count}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
</TableRow>
));
}
if (result.key === "admin_audit") {
return result.raw.map((item) => (
<TableRow key={item.id}>
@@ -1598,6 +1522,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{filteredReports.length === 0 ? (
<AdminNoResourceState message={t("empty")} />
) : (
<>
<Card className="admin-list-card">
<CardHeader className="admin-list-header pb-3">
<div className="flex flex-col gap-3">
@@ -1652,11 +1580,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</CardContent>
</Card>
<div className="grid gap-2 md:grid-cols-4">
<div className="grid min-w-0 gap-2 md:grid-cols-4">
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
<div key={item.label} className={cn("rounded-md border px-3 py-2.5", statTone(item.tone))}>
<div key={item.label} className={cn("min-w-0 rounded-md border px-3 py-2.5", statTone(item.tone))}>
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{item.value}</div>
<AdminMoneyDisplay as="div" value={item.value} size="md" className="mt-0.5">
{item.value}
</AdminMoneyDisplay>
</div>
))}
</div>
@@ -1722,6 +1652,8 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
) : null}
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { ArrowRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
import { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { cn } from "@/lib/utils";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
@@ -64,9 +65,15 @@ export function SettlementBillSummaryHeader({
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "结算金额" })}
</p>
<p className="mt-0.5 text-2xl font-bold tabular-nums tracking-tight text-foreground">
<AdminMoneyDisplay
as="p"
value={formatDashboardMoneyMinor(direction.amount, currencyCode)}
size="xl"
emphasize
className="mt-0.5 text-foreground"
>
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</p>
</AdminMoneyDisplay>
</div>
</div>
@@ -75,9 +82,15 @@ export function SettlementBillSummaryHeader({
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
</p>
<p className="mt-0.5 font-medium tabular-nums">
<AdminMoneyDisplay
as="p"
value={formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)}
size="md"
emphasize={false}
className="mt-0.5"
>
{formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)}
</p>
</AdminMoneyDisplay>
</div>
<div
className={cn(
@@ -90,14 +103,14 @@ export function SettlementBillSummaryHeader({
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
</p>
<p
className={cn(
"mt-0.5 font-semibold tabular-nums",
unpaid && "text-amber-900 dark:text-amber-200",
)}
<AdminMoneyDisplay
as="p"
value={formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
size="md"
className={cn("mt-0.5", unpaid && "text-amber-900 dark:text-amber-200")}
>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</p>
</AdminMoneyDisplay>
{unpaid ? (
<p className="mt-1 text-xs text-muted-foreground">
{bill.status === "pending_confirm"

View File

@@ -8,6 +8,7 @@ import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
@@ -243,16 +244,20 @@ export function SettlementBillsTable({
)}
</TableCell>
) : null}
<TableCell className="text-right tabular-nums">
<div className={cn("font-semibold", signedMoneyClass(row.net_amount, true))}>
<TableCell className={adminMoneyCellClassName(cn("text-right", signedMoneyClass(row.net_amount, true)))}>
<AdminTableMoney>
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</div>
</AdminTableMoney>
</TableCell>
<TableCell className={cn("text-right tabular-nums", paidMoneyClass(row))}>
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
<TableCell className={adminMoneyCellClassName(cn("text-right", paidMoneyClass(row)))}>
<AdminTableMoney>
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
</AdminTableMoney>
</TableCell>
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
<TableCell className={adminMoneyCellClassName(cn("text-right", unpaidMoneyClass(row)))}>
<AdminTableMoney>
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
</AdminTableMoney>
</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>

View File

@@ -34,6 +34,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { LotteryApiBizError } from "@/types/api/errors";
import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label";
@@ -368,8 +369,10 @@ export function SettlementOperationsPanel({
<TableCell className="tabular-nums">
{row.billId > 0 ? `#${row.billId}` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
<TableCell className={adminMoneyCellClassName("text-right font-medium")}>
<AdminTableMoney>
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</AdminTableMoney>
</TableCell>
<TableCell className="max-w-[160px] truncate text-sm">{row.summary}</TableCell>
<TableCell className="max-w-[240px] truncate text-sm text-muted-foreground">

View File

@@ -59,6 +59,7 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
import { formatAdminMinorUnits } from "@/lib/money";
import { creditLedgerReasonLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -578,8 +579,10 @@ export function TransferOrdersPanel(): React.ReactElement {
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell>{row.direction}</TableCell>
<TableCell className="tabular-nums">
{formatAdminMinorUnits(row.amount, row.currency_code)}
<TableCell className={adminMoneyCellClassName("text-right")}>
<AdminTableMoney>
{formatAdminMinorUnits(row.amount, row.currency_code)}
</AdminTableMoney>
</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
@@ -907,8 +910,10 @@ export function WalletTxnsPanel(): React.ReactElement {
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
</span>
</TableCell>
<TableCell className="min-w-[6.5rem] align-top whitespace-nowrap tabular-nums text-xs">
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
<TableCell className={adminMoneyCellClassName("min-w-[6.5rem] text-right text-xs")}>
<AdminTableMoney>
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
</AdminTableMoney>
<span className="ml-1 text-muted-foreground">
({row.direction === 1 ? t("in") : t("out")})
</span>
@@ -1031,9 +1036,13 @@ export function PlayerWalletPanel(): React.ReactElement {
<TableRow key={w.id}>
<TableCell>{w.wallet_type}</TableCell>
<TableCell>{w.currency_code}</TableCell>
<TableCell className="font-mono tabular-nums">{w.balance}</TableCell>
<TableCell className="tabular-nums">
{formatAdminMinorUnits(w.available_balance, w.currency_code)}
<TableCell className={adminMoneyCellClassName("font-mono text-right")}>
<AdminTableMoney>{w.balance}</AdminTableMoney>
</TableCell>
<TableCell className={adminMoneyCellClassName("text-right")}>
<AdminTableMoney>
{formatAdminMinorUnits(w.available_balance, w.currency_code)}
</AdminTableMoney>
</TableCell>
</TableRow>
))