Files
lotteryAdmin/src/modules/players/player-detail-console.tsx
kang cbc499e5b2 feat(api, agents): add agent node profile retrieval and update functionality
Implemented new API functions to fetch and update agent node profiles, enhancing the management capabilities for agent data. This addition improves the overall functionality of the admin agents console, allowing for better user interaction with agent profiles. Updated related types for improved type safety and clarity in the codebase.
2026-06-04 09:17:55 +08:00

563 lines
23 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 { 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 { formatAdminMinorUnits } from "@/lib/money";
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 [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 [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 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]);
const loadTickets = useCallback(async () => {
setTicketsLoading(true);
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 {
setTickets([]);
setTicketTotal(0);
setTicketLastPage(1);
} finally {
setTicketsLoading(false);
}
}, [playerId, ticketPage, ticketPerPage]);
const loadTxns = useCallback(async () => {
setTxnsLoading(true);
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 {
setTxns([]);
setTxnTotal(0);
setTxnLastPage(1);
} finally {
setTxnsLoading(false);
}
}, [playerId, txnPage, txnPerPage]);
const loadTransfers = useCallback(async () => {
setTransfersLoading(true);
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 {
setTransfers([]);
setTransferTotal(0);
setTransferLastPage(1);
} finally {
setTransfersLoading(false);
}
}, [playerId, transferPage, transferPerPage]);
useAsyncEffect(() => {
void loadPlayer();
}, [loadPlayer]);
useAsyncEffect(() => {
if (!player) return;
void loadTickets();
}, [player, loadTickets]);
useAsyncEffect(() => {
if (!player) return;
void loadTxns();
}, [player, loadTxns]);
useAsyncEffect(() => {
if (!player) return;
void loadTransfers();
}, [player, loadTransfers]);
if (playerLoading && !player) {
return <AdminLoadingState minHeight="8rem" className="py-8" />;
}
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>
<p className="text-sm text-destructive">{playerErr ?? t("states.noData", { ns: "common" })}</p>
</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="w-full justify-start border-b rounded-none bg-transparent p-0">
<TabsTrigger value="overview" className="rounded-none px-3">
{t("tabOverview")}
</TabsTrigger>
<TabsTrigger value="tickets" className="rounded-none px-3">
{t("tabTickets")}
</TabsTrigger>
<TabsTrigger value="wallet" className="rounded-none px-3">
{t("tabWalletTxns")}
</TabsTrigger>
<TabsTrigger value="transfers" className="rounded-none px-3">
{t("tabTransferOrders")}
</TabsTrigger>
</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("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">{t("walletsSection")}</CardTitle>
</CardHeader>
<CardContent>
{player.wallets.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("states.noData", { ns: "common" })}</p>
) : (
<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">
<CardTitle className="admin-list-title">{t("tabTickets")}</CardTitle>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("ticketNo", { 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("placedAt", { ns: "tickets" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ticketsLoading && tickets.length === 0 ? (
<AdminTableLoadingRow colSpan={7} />
) : null}
{tickets.map((row) => (
<TableRow key={row.ticket_no}>
<TableCell className="font-mono text-xs">{row.ticket_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="text-xs text-muted-foreground">
{row.placed_at ? formatDt(row.placed_at) : "—"}
</TableCell>
</TableRow>
))}
{!ticketsLoading && tickets.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : 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">{t("tabWalletTxns")}</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 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : 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>
<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">
<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 ? (
<TableRow>
<TableCell colSpan={5} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : 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>
</Tabs>
</div>
);
}