feat(admin, players): enhance player management and admin interfaces
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

Updated the player management components to improve error handling and display for various operations, including ticket loading and transaction retrieval. Introduced new status badges for better visual representation of player statuses. Enhanced localization support for error messages and improved the overall user experience in the admin console. Refactored components for better maintainability and clarity.
This commit is contained in:
2026-06-22 10:50:59 +08:00
parent d6c7d2c361
commit c6119da9f8
31 changed files with 777 additions and 463 deletions

View File

@@ -29,7 +29,9 @@ 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,
@@ -115,6 +117,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
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);
@@ -122,6 +125,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
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);
@@ -129,6 +133,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
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);
@@ -145,6 +150,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
const loadTickets = useCallback(async () => {
setTicketsLoading(true);
setTicketsErr(null);
try {
const d = await getAdminPlayerTicketItems(playerId, {
page: ticketPage,
@@ -153,17 +159,19 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
setTickets(d.items);
setTicketTotal(d.total);
setTicketLastPage(Math.max(1, d.last_page));
} catch {
} catch (e) {
setTickets([]);
setTicketTotal(0);
setTicketLastPage(1);
setTicketsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally {
setTicketsLoading(false);
}
}, [playerId, ticketPage, ticketPerPage]);
}, [playerId, ticketPage, ticketPerPage, tRef]);
const loadTxns = useCallback(async () => {
setTxnsLoading(true);
setTxnsErr(null);
try {
const d = await getAdminWalletTransactions({
player_id: playerId,
@@ -173,17 +181,19 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
setTxns(d.items);
setTxnTotal(d.total);
setTxnLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
} catch {
} catch (e) {
setTxns([]);
setTxnTotal(0);
setTxnLastPage(1);
setTxnsErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally {
setTxnsLoading(false);
}
}, [playerId, txnPage, txnPerPage]);
}, [playerId, txnPage, txnPerPage, tRef]);
const loadTransfers = useCallback(async () => {
setTransfersLoading(true);
setTransfersErr(null);
try {
const d = await getAdminTransferOrders({
player_id: playerId,
@@ -193,14 +203,15 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
setTransfers(d.items);
setTransferTotal(d.total);
setTransferLastPage(Math.max(1, Math.ceil(d.total / d.per_page) || 1));
} catch {
} catch (e) {
setTransfers([]);
setTransferTotal(0);
setTransferLastPage(1);
setTransfersErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed"));
} finally {
setTransfersLoading(false);
}
}, [playerId, transferPage, transferPerPage]);
}, [playerId, transferPage, transferPerPage, tRef]);
useAsyncEffect(() => {
void loadPlayer();
@@ -212,7 +223,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
}, [player, loadTickets]);
useAsyncEffect(() => {
if (!player) return;
if (!player || isCreditFundingPlayer(player)) return;
void loadTxns();
}, [player, loadTxns]);
@@ -344,27 +355,27 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<dl className="grid gap-3 sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">{t("creditLimit")}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
<AdminTableMoney className="mt-1 text-sm font-semibold">
{player.credit_limit != null
? formatPlayerCreditAmount(player.credit_limit, player.default_currency)
: "—"}
</p>
</AdminTableMoney>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("availableCredit")}</p>
<p className="mt-1 text-sm font-semibold tabular-nums">
<AdminTableMoney className="mt-1 text-sm font-semibold">
{player.available_credit != null
? formatPlayerCreditAmount(player.available_credit, player.default_currency)
: "—"}
</p>
</AdminTableMoney>
</div>
<div>
<p className="text-xs text-muted-foreground">{t("usedCredit")}</p>
<p className="mt-1 text-sm tabular-nums text-muted-foreground">
<AdminTableMoney className="mt-1 text-sm text-muted-foreground">
{player.used_credit != null
? formatPlayerCreditAmount(player.used_credit, player.default_currency)
: "—"}
</p>
</AdminTableMoney>
</div>
</dl>
) : player.wallets.length === 0 ? (
@@ -417,6 +428,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</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>
@@ -507,59 +521,76 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<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 tabular-nums">
{formatAdminMinorUnits(row.amount, player.default_currency)}
</TableCell>
<TableCell className="text-center text-xs tabular-nums">
{formatAdminMinorUnits(row.balance_after, player.default_currency)}
</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 ? (
<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}
/>
{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>
@@ -571,6 +602,9 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
<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>
@@ -603,7 +637,7 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</TableCell>
</TableRow>
))}
{!transfersLoading && transfers.length === 0 ? (
{!transfersLoading && transfers.length === 0 && !transfersErr ? (
<AdminTableNoResourceRow colSpan={5} className="text-muted-foreground" />
) : null}
</TableBody>