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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
196
src/modules/dashboard/site-cs-dashboard-console.tsx
Normal file
196
src/modules/dashboard/site-cs-dashboard-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
src/modules/dashboard/site-finance-dashboard-console.tsx
Normal file
244
src/modules/dashboard/site-finance-dashboard-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user