Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- Simplified share rate calculation in AgentsConsole by removing unnecessary checks and directly setting the profile share rate. - Updated the use of `profileParentCaps` to always return total share rate in the agent profile. - Removed unused variables and memoized calculations for improved performance. - Cleaned up imports in various files, removing unused components and optimizing the code structure. - Added `tRef` dependency to several useEffect hooks to ensure proper reactivity to translation changes. - Enhanced report preview tables with better label handling for various statuses and actions. - Updated wallet filter options to align with player-side transaction types. - Introduced new properties in types for better type safety and clarity.
667 lines
29 KiB
TypeScript
667 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import { useCallback, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
|
|
|
import { getAdminPlayer } from "@/api/admin-player";
|
|
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
|
import { getAdminTransferOrders, getAdminWalletTransactions } from "@/api/admin-wallet";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
|
import { buttonVariants } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
|
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
|
import { AdminTableMoney } from "@/components/admin/admin-table-money";
|
|
import { formatAdminMinorUnits } from "@/lib/money";
|
|
import { PlayerCreditLedgerTab } from "@/modules/players/player-credit-ledger-tab";
|
|
import {
|
|
isCreditFundingPlayer,
|
|
playerAuthSourceLabel,
|
|
playerFundingModeLabel,
|
|
playerShowsTransferOrders,
|
|
} from "@/lib/player-funding";
|
|
import { formatPlayerCreditAmount } from "@/lib/admin-player-display";
|
|
import { cn } from "@/lib/utils";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { AdminPlayerRow, AdminPlayerWalletRow } from "@/types/api/admin-player";
|
|
import type { AdminPlayerTicketItemRow } from "@/types/api/admin-player-tickets";
|
|
import type { AdminTransferOrderItem, AdminWalletTxnItem } from "@/types/api/admin-wallet";
|
|
|
|
function playerStatusLabel(status: number, t: (key: string) => string): string {
|
|
if (status === 0) return t("statusNormal");
|
|
if (status === 1) return t("statusFrozen");
|
|
if (status === 2) return t("statusBanned");
|
|
return String(status);
|
|
}
|
|
|
|
function ticketStatusText(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
|
|
const key = `statusOptions.${status}`;
|
|
const translated = t(key, { ns: "tickets" });
|
|
return translated === key ? status : translated;
|
|
}
|
|
|
|
function walletStatusLabel(status: string, t: (key: string, opts?: { ns?: string }) => string): string {
|
|
switch (status) {
|
|
case "processing":
|
|
return t("statusProcessing", { ns: "wallet" });
|
|
case "success":
|
|
return t("statusSuccess", { ns: "wallet" });
|
|
case "failed":
|
|
return t("statusFailed", { ns: "wallet" });
|
|
case "pending_reconcile":
|
|
return t("statusPendingReconcile", { ns: "wallet" });
|
|
case "reversed":
|
|
return t("statusReversed", { ns: "wallet" });
|
|
case "manually_processed":
|
|
return t("statusCaseClosed", { ns: "wallet" });
|
|
case "posted":
|
|
return t("statusPosted", { ns: "wallet" });
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
function PlayerStatusBadge({ status, t }: { status: number; t: (key: string) => string }) {
|
|
return (
|
|
<AdminStatusBadge status={String(status)} tone={resolvePlayerStatusTone(status)}>
|
|
{playerStatusLabel(status, t)}
|
|
</AdminStatusBadge>
|
|
);
|
|
}
|
|
|
|
function playerDisplayName(row: AdminPlayerRow): string {
|
|
return row.nickname?.trim() || row.username?.trim() || row.site_player_id;
|
|
}
|
|
|
|
function ProfileField({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="grid gap-1 sm:grid-cols-[7.5rem_1fr] sm:items-start">
|
|
<dt className="text-xs font-medium text-muted-foreground">{label}</dt>
|
|
<dd className="text-sm">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
|
const { t } = useTranslation(["players", "tickets", "wallet", "common"]);
|
|
const tRef = useTranslationRef(["players", "tickets", "wallet", "common"]);
|
|
const formatDt = useAdminDateTimeFormatter();
|
|
const playCodeLabel = useAdminPlayCodeLabel();
|
|
useAdminCurrencyCatalog();
|
|
|
|
const [player, setPlayer] = useState<AdminPlayerRow | null>(null);
|
|
const [playerLoading, setPlayerLoading] = useState(true);
|
|
const [playerErr, setPlayerErr] = useState<string | null>(null);
|
|
|
|
const [ticketPage, setTicketPage] = useState(1);
|
|
const [ticketPerPage, setTicketPerPage] = useState(10);
|
|
const [tickets, setTickets] = useState<AdminPlayerTicketItemRow[]>([]);
|
|
const [ticketTotal, setTicketTotal] = useState(0);
|
|
const [ticketLastPage, setTicketLastPage] = useState(1);
|
|
const [ticketsLoading, setTicketsLoading] = useState(false);
|
|
const [ticketsErr, setTicketsErr] = useState<string | null>(null);
|
|
|
|
const [txnPage, setTxnPage] = useState(1);
|
|
const [txnPerPage, setTxnPerPage] = useState(10);
|
|
const [txns, setTxns] = useState<AdminWalletTxnItem[]>([]);
|
|
const [txnTotal, setTxnTotal] = useState(0);
|
|
const [txnLastPage, setTxnLastPage] = useState(1);
|
|
const [txnsLoading, setTxnsLoading] = useState(false);
|
|
const [txnsErr, setTxnsErr] = useState<string | null>(null);
|
|
|
|
const [transferPage, setTransferPage] = useState(1);
|
|
const [transferPerPage, setTransferPerPage] = useState(10);
|
|
const [transfers, setTransfers] = useState<AdminTransferOrderItem[]>([]);
|
|
const [transferTotal, setTransferTotal] = useState(0);
|
|
const [transferLastPage, setTransferLastPage] = useState(1);
|
|
const [transfersLoading, setTransfersLoading] = useState(false);
|
|
const [transfersErr, setTransfersErr] = useState<string | null>(null);
|
|
|
|
const loadPlayer = useCallback(async () => {
|
|
setPlayerLoading(true);
|
|
setPlayerErr(null);
|
|
try {
|
|
setPlayer(await getAdminPlayer(playerId));
|
|
} catch (e) {
|
|
setPlayer(null);
|
|
setPlayerErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
|
} finally {
|
|
setPlayerLoading(false);
|
|
}
|
|
}, [playerId, tRef]);
|
|
|
|
const loadTickets = useCallback(async () => {
|
|
setTicketsLoading(true);
|
|
setTicketsErr(null);
|
|
try {
|
|
const d = await getAdminPlayerTicketItems(playerId, {
|
|
page: ticketPage,
|
|
per_page: ticketPerPage,
|
|
});
|
|
setTickets(d.items);
|
|
setTicketTotal(d.total);
|
|
setTicketLastPage(Math.max(1, d.last_page));
|
|
} catch (e) {
|
|
setTickets([]);
|
|
setTicketTotal(0);
|
|
setTicketLastPage(1);
|
|
setTicketsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
|
} finally {
|
|
setTicketsLoading(false);
|
|
}
|
|
}, [playerId, ticketPage, ticketPerPage, tRef]);
|
|
|
|
const loadTxns = useCallback(async () => {
|
|
setTxnsLoading(true);
|
|
setTxnsErr(null);
|
|
try {
|
|
const d = await getAdminWalletTransactions({
|
|
player_id: playerId,
|
|
page: txnPage,
|
|
per_page: txnPerPage,
|
|
});
|
|
setTxns(d.items);
|
|
setTxnTotal(d.total);
|
|
setTxnLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
|
} catch (e) {
|
|
setTxns([]);
|
|
setTxnTotal(0);
|
|
setTxnLastPage(1);
|
|
setTxnsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
|
} finally {
|
|
setTxnsLoading(false);
|
|
}
|
|
}, [playerId, txnPage, txnPerPage, tRef]);
|
|
|
|
const loadTransfers = useCallback(async () => {
|
|
setTransfersLoading(true);
|
|
setTransfersErr(null);
|
|
try {
|
|
const d = await getAdminTransferOrders({
|
|
player_id: playerId,
|
|
page: transferPage,
|
|
per_page: transferPerPage,
|
|
});
|
|
setTransfers(d.items);
|
|
setTransferTotal(d.total);
|
|
setTransferLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
|
|
} catch (e) {
|
|
setTransfers([]);
|
|
setTransferTotal(0);
|
|
setTransferLastPage(1);
|
|
setTransfersErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
|
|
} finally {
|
|
setTransfersLoading(false);
|
|
}
|
|
}, [playerId, transferPage, transferPerPage, tRef]);
|
|
|
|
useAsyncEffect(() => {
|
|
void loadPlayer();
|
|
}, [loadPlayer]);
|
|
|
|
useAsyncEffect(() => {
|
|
if (!player) return;
|
|
void loadTickets();
|
|
}, [player, loadTickets]);
|
|
|
|
useAsyncEffect(() => {
|
|
if (!player || isCreditFundingPlayer(player)) return;
|
|
void loadTxns();
|
|
}, [player, loadTxns]);
|
|
|
|
useAsyncEffect(() => {
|
|
if (!player || !playerShowsTransferOrders(player)) return;
|
|
void loadTransfers();
|
|
}, [player, loadTransfers]);
|
|
|
|
if (playerLoading && !player) {
|
|
return <AdminLoadingState minHeight="8rem" className="py-8" />;
|
|
}
|
|
|
|
const isCreditPlayer = player ? isCreditFundingPlayer(player) : false;
|
|
const showTransferTab = player ? playerShowsTransferOrders(player) : false;
|
|
|
|
if (playerErr || !player) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Link href="/admin/players" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "gap-1")}>
|
|
<ArrowLeft className="size-4" aria-hidden />
|
|
{t("backToList")}
|
|
</Link>
|
|
{playerErr ? (
|
|
<p className="text-sm text-destructive">{playerErr}</p>
|
|
) : (
|
|
<AdminNoResourceState />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="space-y-2">
|
|
<Link
|
|
href="/admin/players"
|
|
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "-ml-2 gap-1")}
|
|
>
|
|
<ArrowLeft className="size-4" aria-hidden />
|
|
{t("backToList")}
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-xl font-semibold tracking-tight">{playerDisplayName(player)}</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{t("detailSubtitle", {
|
|
site: player.site_code,
|
|
sitePlayerId: player.site_player_id,
|
|
playerId: player.id,
|
|
})}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<PlayerStatusBadge status={player.status} t={t} />
|
|
</div>
|
|
|
|
<Tabs defaultValue="overview" className="gap-4">
|
|
<TabsList
|
|
variant="line"
|
|
className="h-auto w-full justify-start gap-1 rounded-none border-b border-border/60 bg-transparent p-0 px-1"
|
|
>
|
|
<TabsTrigger value="overview">{t("tabOverview")}</TabsTrigger>
|
|
<TabsTrigger value="tickets">{t("tabTickets")}</TabsTrigger>
|
|
<TabsTrigger value="wallet">
|
|
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
|
|
</TabsTrigger>
|
|
{showTransferTab ? (
|
|
<TabsTrigger value="transfers">{t("tabTransferOrders")}</TabsTrigger>
|
|
) : null}
|
|
</TabsList>
|
|
|
|
<TabsContent value="overview" className="mt-0 space-y-4">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">{t("profileSection")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
<dl className="space-y-3">
|
|
<ProfileField label={t("site")}>{player.site_code}</ProfileField>
|
|
<ProfileField label={t("sitePlayerId")}>
|
|
<span className="font-mono">{player.site_player_id}</span>
|
|
</ProfileField>
|
|
<ProfileField label="ID">
|
|
<span className="font-mono">{player.id}</span>
|
|
</ProfileField>
|
|
<ProfileField label={t("username")}>{player.username ?? "—"}</ProfileField>
|
|
<ProfileField label={t("nickname")}>{player.nickname ?? "—"}</ProfileField>
|
|
</dl>
|
|
<dl className="space-y-3">
|
|
<ProfileField label={t("currency")}>{player.default_currency}</ProfileField>
|
|
<ProfileField label={t("fundingMode")}>
|
|
{playerFundingModeLabel(player, t)}
|
|
</ProfileField>
|
|
<ProfileField label={t("authSource")}>
|
|
{playerAuthSourceLabel(player, t)}
|
|
</ProfileField>
|
|
<ProfileField label={t("riskTags", { defaultValue: "风控标签" })}>
|
|
{player.risk_tags && player.risk_tags.length > 0 ? player.risk_tags.join(", ") : "—"}
|
|
</ProfileField>
|
|
<ProfileField label={t("status")}>
|
|
<PlayerStatusBadge status={player.status} t={t} />
|
|
</ProfileField>
|
|
<ProfileField label={t("lastLogin")}>
|
|
{player.last_login_at ? formatDt(player.last_login_at) : "—"}
|
|
</ProfileField>
|
|
<ProfileField label={t("createdAt")}>
|
|
{formatDt(player.created_at)}
|
|
</ProfileField>
|
|
<ProfileField label={t("agent")}>
|
|
{player.agent_name ?? player.agent_code ?? "—"}
|
|
{player.agent_code && player.agent_name ? (
|
|
<span className="ml-1 font-mono text-xs text-muted-foreground">
|
|
({player.agent_code})
|
|
</span>
|
|
) : null}
|
|
</ProfileField>
|
|
</dl>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">
|
|
{isCreditPlayer ? t("creditSection") : t("walletsSection")}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isCreditPlayer ? (
|
|
<dl className="grid gap-3 sm:grid-cols-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{t("creditLimit")}</p>
|
|
<AdminTableMoney className="mt-1 text-sm font-semibold">
|
|
{player.credit_limit != null
|
|
? formatPlayerCreditAmount(player.credit_limit, player.default_currency)
|
|
: "—"}
|
|
</AdminTableMoney>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{t("availableCredit")}</p>
|
|
<AdminTableMoney className="mt-1 text-sm font-semibold">
|
|
{player.available_credit != null
|
|
? formatPlayerCreditAmount(player.available_credit, player.default_currency)
|
|
: "—"}
|
|
</AdminTableMoney>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">{t("usedCredit")}</p>
|
|
<AdminTableMoney className="mt-1 text-sm text-muted-foreground">
|
|
{player.used_credit != null
|
|
? formatPlayerCreditAmount(player.used_credit, player.default_currency)
|
|
: "—"}
|
|
</AdminTableMoney>
|
|
</div>
|
|
</dl>
|
|
) : player.wallets.length === 0 ? (
|
|
<AdminNoResourceState className="text-sm text-muted-foreground" />
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{player.wallets.map((w: AdminPlayerWalletRow) => (
|
|
<div
|
|
key={`${w.wallet_type}-${w.currency_code}`}
|
|
className="rounded-lg border bg-muted/20 px-3 py-2.5"
|
|
>
|
|
<p className="text-xs font-medium text-muted-foreground">
|
|
{w.wallet_type} · {w.currency_code}
|
|
</p>
|
|
<p className="mt-1 text-sm">
|
|
{t("balance")}{" "}
|
|
<span className="font-semibold tabular-nums">
|
|
{formatAdminMinorUnits(w.balance, w.currency_code)}
|
|
</span>
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("available")}{" "}
|
|
<span className="tabular-nums">
|
|
{formatAdminMinorUnits(w.available_balance, w.currency_code)}
|
|
</span>
|
|
{w.frozen_balance > 0 ? (
|
|
<>
|
|
{" · "}
|
|
{t("frozen", { defaultValue: "冻结" })}{" "}
|
|
<span className="tabular-nums">
|
|
{formatAdminMinorUnits(w.frozen_balance, w.currency_code)}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tickets" className="mt-0">
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header">
|
|
<div className="space-y-1">
|
|
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
|
|
<p className="text-sm text-muted-foreground">{t("ticketTableHint")}</p>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content">
|
|
{ticketsErr ? (
|
|
<p className="mb-3 text-sm text-destructive">{ticketsErr}</p>
|
|
) : null}
|
|
<div className="admin-table-shell">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("ticketNo", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("orderNo", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("drawNo", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("playCode", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("number", { ns: "tickets" })}</TableHead>
|
|
<TableHead className="text-center">{t("actualDeduct", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("status", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("failReason", { ns: "tickets" })}</TableHead>
|
|
<TableHead className="text-center">{t("winAmount", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("placedAt", { ns: "tickets" })}</TableHead>
|
|
<TableHead>{t("updatedAt", { ns: "tickets" })}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{ticketsLoading && tickets.length === 0 ? (
|
|
<AdminTableLoadingRow colSpan={10} />
|
|
) : null}
|
|
{tickets.map((row) => (
|
|
<TableRow key={row.ticket_no}>
|
|
<TableCell className="font-mono text-xs">
|
|
<Link
|
|
href={`/admin/tickets/${encodeURIComponent(row.ticket_no)}`}
|
|
className="text-primary hover:underline"
|
|
>
|
|
{row.ticket_no}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
|
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
|
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
|
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
|
<TableCell className="text-center text-xs tabular-nums">
|
|
{row.actual_deduct_amount_formatted}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<AdminStatusBadge status={row.status}>
|
|
{ticketStatusText(row.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
|
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
|
</TableCell>
|
|
<TableCell className="text-center text-xs tabular-nums">
|
|
{row.jackpot_win_amount_minor > 0
|
|
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
|
|
: row.win_amount_formatted}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{row.placed_at ? formatDt(row.placed_at) : "—"}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{row.updated_at ? formatDt(row.updated_at) : "—"}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{!ticketsLoading && tickets.length === 0 ? (
|
|
<AdminTableNoResourceRow colSpan={10} className="text-muted-foreground" />
|
|
) : null}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<AdminListPaginationFooter
|
|
selectId="player-detail-tickets"
|
|
total={ticketTotal}
|
|
page={ticketPage}
|
|
lastPage={ticketLastPage}
|
|
perPage={ticketPerPage}
|
|
loading={ticketsLoading}
|
|
onPerPageChange={(n) => {
|
|
setTicketPerPage(n);
|
|
setTicketPage(1);
|
|
}}
|
|
onPageChange={setTicketPage}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="wallet" className="mt-0">
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header">
|
|
<CardTitle className="admin-list-title">
|
|
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content">
|
|
{isCreditPlayer ? (
|
|
<PlayerCreditLedgerTab
|
|
playerId={player.id}
|
|
siteCode={player.site_code}
|
|
currencyCode={player.default_currency}
|
|
/>
|
|
) : (
|
|
<>
|
|
{txnsErr ? (
|
|
<p className="mb-3 text-sm text-destructive">{txnsErr}</p>
|
|
) : null}
|
|
<div className="admin-table-shell">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("txnNo", { ns: "wallet" })}</TableHead>
|
|
<TableHead>{t("bizType", { ns: "wallet" })}</TableHead>
|
|
<TableHead className="text-center">{t("txnAmount")}</TableHead>
|
|
<TableHead className="text-center">{t("balanceAfterTxn")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead>{t("createdAt")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{txnsLoading && txns.length === 0 ? <AdminTableLoadingRow colSpan={6} /> : null}
|
|
{txns.map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
|
<TableCell className="text-xs">{row.biz_type}</TableCell>
|
|
<TableCell className="text-center text-xs">
|
|
<AdminTableMoney>
|
|
{row.amount_formatted ?? formatAdminMinorUnits(row.amount, player.default_currency)}
|
|
</AdminTableMoney>
|
|
</TableCell>
|
|
<TableCell className="text-center text-xs">
|
|
<AdminTableMoney>
|
|
{formatAdminMinorUnits(row.balance_after, player.default_currency)}
|
|
</AdminTableMoney>
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<AdminStatusBadge status={row.status}>
|
|
{walletStatusLabel(row.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{row.created_at ? formatDt(row.created_at) : "—"}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{!txnsLoading && txns.length === 0 && !txnsErr ? (
|
|
<AdminTableNoResourceRow colSpan={6} className="text-muted-foreground" />
|
|
) : null}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<AdminListPaginationFooter
|
|
selectId="player-detail-txns"
|
|
total={txnTotal}
|
|
page={txnPage}
|
|
lastPage={txnLastPage}
|
|
perPage={txnPerPage}
|
|
loading={txnsLoading}
|
|
onPerPageChange={(n) => {
|
|
setTxnPerPage(n);
|
|
setTxnPage(1);
|
|
}}
|
|
onPageChange={setTxnPage}
|
|
/>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{showTransferTab ? (
|
|
<TabsContent value="transfers" className="mt-0">
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header">
|
|
<CardTitle className="admin-list-title">{t("tabTransferOrders")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content">
|
|
{transfersErr ? (
|
|
<p className="mb-3 text-sm text-destructive">{transfersErr}</p>
|
|
) : null}
|
|
<div className="admin-table-shell">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t("localTransferNo", { ns: "wallet" })}</TableHead>
|
|
<TableHead>{t("direction", { ns: "wallet" })}</TableHead>
|
|
<TableHead className="text-center">{t("amount", { ns: "wallet" })}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead>{t("requestTime", { ns: "wallet" })}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{transfersLoading && transfers.length === 0 ? (
|
|
<AdminTableLoadingRow colSpan={5} />
|
|
) : null}
|
|
{transfers.map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="font-mono text-xs">{row.transfer_no}</TableCell>
|
|
<TableCell className="text-xs">{row.direction}</TableCell>
|
|
<TableCell className="text-center text-xs tabular-nums">
|
|
{formatAdminMinorUnits(row.amount, row.currency_code)}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<AdminStatusBadge status={row.status}>
|
|
{walletStatusLabel(row.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{row.created_at ? formatDt(row.created_at) : "—"}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{!transfersLoading && transfers.length === 0 && !transfersErr ? (
|
|
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
|
|
) : null}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<AdminListPaginationFooter
|
|
selectId="player-detail-transfers"
|
|
total={transferTotal}
|
|
page={transferPage}
|
|
lastPage={transferLastPage}
|
|
perPage={transferPerPage}
|
|
loading={transfersLoading}
|
|
onPerPageChange={(n) => {
|
|
setTransferPerPage(n);
|
|
setTransferPage(1);
|
|
}}
|
|
onPageChange={setTransferPage}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
) : null}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|