"use client"; import { useCallback, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Copy, RotateCcw, Wrench } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { toast } from "sonner"; import { getAdminPlayerWallets, getAdminTransferOrders, getAdminWalletTransactions, reverseTransferOrder, manuallyProcessTransferOrder, completeTransferInCredit, } from "@/api/admin-wallet"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns"; import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; import { formatAdminMinorUnits } from "@/lib/money"; import { cn } from "@/lib/utils"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminPlayerWalletsData, AdminTransferOrderItem, AdminTransferOrderListData, AdminWalletTxnListData, } from "@/types/api/admin-wallet"; /** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */ function CellMonoId({ value, empty = "—", copyHint, }: { value: string | null | undefined; empty?: string; /** 用于 toast / 无障碍:如「流水号」「主站流水号」 */ copyHint?: string; }): React.ReactElement { const { t } = useTranslation("wallet"); if (value == null || value === "") { return {empty}; } const copy = async (e: React.MouseEvent): Promise => { e.stopPropagation(); try { await navigator.clipboard.writeText(value); toast.success( copyHint ? t("copySuccess", { label: copyHint }) : t("copySuccess", { label: "" }).trim(), ); } catch { toast.error(t("copyFailed")); } }; return ( void copy(e)} > {value} ); } function statusLabelT(status: string, t: (key: string) => string): string { switch (status) { case "processing": return t("statusProcessing"); case "success": return t("statusSuccess"); case "failed": return t("statusFailed"); case "pending_reconcile": return t("statusPendingReconcile"); case "reversed": return t("statusReversed"); case "manually_processed": return t("statusCaseClosed"); case "posted": return t("statusPosted"); default: return status; } } type TransferFilters = { playerId: string; playerAccount: string; transferNo: string; externalRefNo: string; createdFrom: string; createdTo: string; statusCsv: string; abnormalOnly: boolean; }; const emptyTransferFilters: TransferFilters = { playerId: "", playerAccount: "", transferNo: "", externalRefNo: "", createdFrom: "", createdTo: "", statusCsv: "", abnormalOnly: false, }; type TxnFilters = { playerId: string; playerAccount: string; txnNo: string; externalRefNo: string; bizType: string; statusCsv: string; createdFrom: string; createdTo: string; abnormalOnly: boolean; }; const emptyTxnFilters: TxnFilters = { playerId: "", playerAccount: "", txnNo: "", externalRefNo: "", bizType: "", statusCsv: "", createdFrom: "", createdTo: "", abnormalOnly: false, }; /** 下拉「不限」值;请求时转为空串不传参 */ const WALLET_FILTER_ALL = "__all__"; /** 与 {@see WalletTransactionListController}、{@see LotteryTransferService} 当前写入的 biz_type 一致 */ const WALLET_TXN_BIZ_OPTIONS: { value: string; label: string }[] = [ { value: "transfer_in", label: "transferIn" }, { value: "transfer_out", label: "transferOut" }, { value: "transfer_out_refund", label: "transferOutRefund" }, ]; /** 与 {@see WalletTransactionListController::ALLOWED_STATUS} 一致 */ const WALLET_TXN_STATUS_OPTIONS: { value: string; label: string }[] = [ { value: "posted", label: "statusPosted" }, { value: "pending_reconcile", label: "statusPendingReconcile" }, { value: "reversed", label: "statusReversed" }, ]; /** 与 {@see TransferOrderListController::ALLOWED_STATUS} 一致 */ const TRANSFER_ORDER_STATUS_OPTIONS: { value: string; label: string }[] = [ { value: "processing", label: "statusProcessing" }, { value: "success", label: "statusSuccess" }, { value: "failed", label: "statusFailed" }, { value: "pending_reconcile", label: "statusPendingReconcile" }, { value: "reversed", label: "statusReversed" }, { value: "manually_processed", label: "statusCaseClosed" }, ]; /** Base UI 的 SelectValue 会直接显示 `value`,需把哨兵转成「不限」、其余转成选项文案 */ function walletAdminSelectDisplayedLabel( raw: unknown, options: readonly { value: string; label: string }[], t?: (key: string) => string, ): string { const v = raw == null ? "" : String(raw); if (v === "" || v === WALLET_FILTER_ALL) { return t ? t("filterAll") : "All"; } const key = options.find((o) => o.value === v)?.label; return key ? (t ? t(key) : key) : v; } function canReverseTransferOrder( row: { status: string; can_reverse?: boolean }, canWriteWallet: boolean, ): boolean { return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile"); } function canCompleteTransferInCredit( row: { direction: string; status: string; fail_reason?: string | null; external_ref_no?: string | null; can_complete_credit?: boolean; }, canWriteWallet: boolean, ): boolean { return ( canWriteWallet && (row.can_complete_credit ?? (row.direction === "in" && row.status === "pending_reconcile" && row.fail_reason === "lottery_credit_failed" && Boolean(row.external_ref_no?.trim()))) ); } function canManuallyProcessTransferOrder( row: { direction?: string; status: string; fail_reason?: string | null; can_manually_process?: boolean; }, canWriteWallet: boolean, ): boolean { return ( canWriteWallet && (row.can_manually_process ?? (["processing", "failed", "pending_reconcile"].includes(row.status) && !(row.direction === "out" && row.status === "pending_reconcile") && row.fail_reason !== "lottery_credit_failed")) ); } type TransferOrderRowActionsProps = { row: AdminTransferOrderItem; canWriteWallet: boolean; busy: boolean; onCompleteCredit: (transferNo: string) => void; onReverse: (transferNo: string) => void; onManualProcess: (transferNo: string) => void; t: (key: string) => string; }; function TransferOrderRowActions({ row, canWriteWallet, busy, onCompleteCredit, onReverse, onManualProcess, t, }: TransferOrderRowActionsProps): React.ReactElement { return ( onCompleteCredit(row.transfer_no), }, { key: "manual", label: t("markCaseClosed"), icon: Wrench, hidden: !canManuallyProcessTransferOrder(row, canWriteWallet), onClick: () => onManualProcess(row.transfer_no), }, { key: "reverse", label: t("reverse"), icon: RotateCcw, destructive: true, hidden: !canReverseTransferOrder(row, canWriteWallet), onClick: () => onReverse(row.transfer_no), }, ]} /> ); } export function TransferOrdersPanel(): React.ReactElement { const { t } = useTranslation(["wallet", "common"]); const tRef = useTranslationRef(["wallet", "common"]); const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const profile = useAdminProfile(); const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]); const exportLabels = useExportLabels("walletTransferOrders"); useAdminCurrencyCatalog(); const formatTs = useAdminDateTimeFormatter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [draft, setDraft] = useState(emptyTransferFilters); const [applied, setApplied] = useState(emptyTransferFilters); const [actionLoading, setActionLoading] = useState>(new Set()); const doAction = async ( transferNo: string, fn: () => Promise, successMsg: string, ) => { setActionLoading((prev) => new Set(prev).add(transferNo)); try { await fn(); toast.success(successMsg); void load(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed")); } finally { setActionLoading((prev) => { const next = new Set(prev); next.delete(transferNo); return next; }); } }; const handleReverse = (transferNo: string) => requestConfirm({ title: t("confirm.reverseTitle"), description: t("confirm.reverseDescription", { transferNo }), confirmVariant: "destructive", onConfirm: () => doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess")), }); const handleManuallyProcess = (transferNo: string) => requestConfirm({ title: t("confirm.markCaseClosedTitle"), description: t("confirm.markCaseClosedDescription", { transferNo }), onConfirm: () => doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("markCaseClosedSuccess")), }); const handleCompleteCredit = (transferNo: string) => requestConfirm({ title: t("confirm.completeCreditTitle"), description: t("confirm.completeCreditDescription", { transferNo }), onConfirm: () => doAction(transferNo, () => completeTransferInCredit(transferNo), t("completeCreditSuccess")), }); const load = useCallback(async () => { setLoading(true); setErr(null); try { const player_id = applied.playerId.trim() === "" ? undefined : Number(applied.playerId); const d = await getAdminTransferOrders({ page, per_page: perPage, abnormal: applied.abnormalOnly || undefined, player_id: player_id !== undefined && !Number.isNaN(player_id) && player_id > 0 ? player_id : undefined, player_account: applied.playerAccount.trim() || undefined, transfer_no: applied.transferNo.trim() || undefined, external_ref_no: applied.externalRefNo.trim() || undefined, created_from: applied.createdFrom.trim() || undefined, created_to: applied.createdTo.trim() || undefined, status: applied.statusCsv.trim() || undefined, }); setData(d); } catch (e) { setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); setData(null); } finally { setLoading(false); } }, [page, perPage, applied]); useAsyncEffect(() => { void load(); }, [page, perPage, applied]); const runSearch = () => { setApplied({ ...draft }); setPage(1); }; const resetFilters = () => { setDraft(emptyTransferFilters); setApplied(emptyTransferFilters); setPage(1); }; return ( <> {t("transferOrders")} {t("localTransferNo")} setDraft((d) => ({ ...d, transferNo: e.target.value }))} /> {t("externalRefNo")} setDraft((d) => ({ ...d, externalRefNo: e.target.value }))} /> {t("playerAccount")} setDraft((d) => ({ ...d, playerAccount: e.target.value }))} /> {t("playerId")} setDraft((d) => ({ ...d, playerId: e.target.value }))} /> setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to })) } /> {t("status")} o.value === draft.statusCsv) ? WALLET_FILTER_ALL : draft.statusCsv } onValueChange={(v) => setDraft((d) => ({ ...d, statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v), })) } > {(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS, t)} {t("filterAll")} {TRANSFER_ORDER_STATUS_OPTIONS.map((o) => ( {t(o.label)} ))} {t("options")} setDraft((d) => ({ ...d, abnormalOnly: v === true })) } /> {t("abnormalOnly")} runSearch()}> {t("search")} resetFilters()}> {t("resetFilters")} void load()}> {t("refreshCurrentPage")} {err ? {err} : null} {(loading && !data) || data ? ( <> {t("localTransferNo")} {t("externalRefNo")} {t("direction")} {t("amount")} {t("status")} {t("failReason")} {t("requestTime")} {t("finishedTime")} {t("table.actions", { ns: "common" })} {loading && !data ? ( ) : !data || data.items.length === 0 ? ( {t("states.noData", { ns: "common" })} ) : ( data.items.map((row) => ( {row.direction} {formatAdminMinorUnits(row.amount, row.currency_code)} {statusLabelT(row.status, t)} {row.fail_reason?.trim() ? row.fail_reason : "—"} {formatTs(row.created_at)} {formatTs(row.finished_at)} )) )} {data ? ( { setPerPage(next); setPage(1); }} onPageChange={setPage} /> ) : null} > ) : null} > ); } export function WalletTxnsPanel(): React.ReactElement { const { t } = useTranslation(["wallet", "common"]); const tRef = useTranslationRef(["wallet", "common"]); const exportLabels = useExportLabels("walletTransactions"); const formatTs = useAdminDateTimeFormatter(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [draft, setDraft] = useState(emptyTxnFilters); const [applied, setApplied] = useState(emptyTxnFilters); const searchParams = useSearchParams(); const playerIdFromUrl = (searchParams.get("player_id") ?? "").trim(); const load = useCallback(async () => { setLoading(true); setErr(null); try { const player_id = applied.playerId.trim() === "" ? undefined : Number(applied.playerId); const d = await getAdminWalletTransactions({ page, per_page: perPage, abnormal: applied.abnormalOnly || undefined, player_id: player_id !== undefined && !Number.isNaN(player_id) && player_id > 0 ? player_id : undefined, player_account: applied.playerAccount.trim() || undefined, txn_no: applied.txnNo.trim() || undefined, external_ref_no: applied.externalRefNo.trim() || undefined, created_from: applied.createdFrom.trim() || undefined, created_to: applied.createdTo.trim() || undefined, biz_type: applied.bizType.trim() || undefined, status: applied.statusCsv.trim() || undefined, }); setData(d); } catch (e) { setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("loadFailed")); setData(null); } finally { setLoading(false); } }, [page, perPage, applied]); useAsyncEffect(() => { void load(); }, [page, perPage, applied]); useAsyncEffect(() => { if (!playerIdFromUrl) { return; } setDraft((d) => ({ ...d, playerId: playerIdFromUrl })); setApplied((d) => ({ ...d, playerId: playerIdFromUrl })); setPage(1); }, [playerIdFromUrl]); const runSearch = () => { setApplied({ ...draft }); setPage(1); }; const resetFilters = () => { setDraft(emptyTxnFilters); setApplied(emptyTxnFilters); setPage(1); }; return ( {t("walletTransactions")} {t("txnNo")} setDraft((d) => ({ ...d, txnNo: e.target.value }))} /> {t("externalRefNo")} setDraft((d) => ({ ...d, externalRefNo: e.target.value }))} /> {t("playerAccount")} setDraft((d) => ({ ...d, playerAccount: e.target.value }))} /> {t("playerId")} setDraft((d) => ({ ...d, playerId: e.target.value }))} /> {t("bizType")} o.value === draft.bizType) ? WALLET_FILTER_ALL : draft.bizType } onValueChange={(v) => setDraft((d) => ({ ...d, bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v), })) } > {(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)} {t("filterAll")} {WALLET_TXN_BIZ_OPTIONS.map((o) => ( {t(o.label)} ))} {t("status")} o.value === draft.statusCsv) ? WALLET_FILTER_ALL : draft.statusCsv } onValueChange={(v) => setDraft((d) => ({ ...d, statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v), })) } > {(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)} {t("filterAll")} {WALLET_TXN_STATUS_OPTIONS.map((o) => ( {t(o.label)} ))} setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to })) } /> {t("options")} setDraft((d) => ({ ...d, abnormalOnly: v === true })) } /> {t("abnormalOnlyPending")} runSearch()}> {t("search")} resetFilters()}> {t("resetFilters")} void load()}> {t("refreshCurrentPage")} {err ? {err} : null} {(loading && !data) || data ? ( <> {t("txnNo")} {t("externalRefNo")} {t("type")} {t("amount")} {t("status")} {t("requestTime")} {t("finishedTime")} {loading && !data ? ( ) : !data || data.items.length === 0 ? ( {t("states.noData", { ns: "common" })} ) : ( data.items.map((row) => ( {row.biz_type} {row.amount} ({row.direction === 1 ? t("in") : t("out")}) {statusLabelT(row.status, t)} {formatTs(row.created_at)} {formatTs(row.updated_at)} )) )} {data ? ( { setPerPage(next); setPage(1); }} onPageChange={setPage} /> ) : null} > ) : null} ); } export function PlayerWalletPanel(): React.ReactElement { const { t } = useTranslation(["wallet", "common"]); const tRef = useTranslationRef(["wallet", "common"]); const exportLabels = useExportLabels("playerWallets"); useAdminCurrencyCatalog(); const [playerId, setPlayerId] = useState(""); const [result, setResult] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); const query = useCallback(async () => { const id = Number(playerId.trim()); if (Number.isNaN(id) || id < 1) { setErr(tRef.current("invalidPlayerId")); setResult(null); return; } setLoading(true); setErr(null); try { const d = await getAdminPlayerWallets(id); setResult(d); } catch (e) { setErr(e instanceof LotteryApiBizError ? e.message : tRef.current("queryFailed")); setResult(null); } finally { setLoading(false); } }, [playerId]); return ( {t("playerWalletQuery")} {t("playerId")} setPlayerId(e.target.value)} className="w-40" /> void query()} disabled={loading}> {loading ? t("querying") : t("query")} {err ? {err} : null} {result ? ( {t("sitePlayer")}{" "} {result.player.site_code}:{result.player.site_player_id} {t("walletType")} {t("currency")} {t("balanceMinor")} {t("availableBalance")} {result.wallets.length === 0 ? ( {t("noWalletRows")} ) : ( result.wallets.map((w) => ( {w.wallet_type} {w.currency_code} {w.balance} {formatAdminMinorUnits(w.available_balance, w.currency_code)} )) )} ) : null} ); }
{err}
{t("sitePlayer")}{" "} {result.player.site_code}:{result.player.site_player_id}