Files
lotteryAdmin/src/modules/wallet/wallet-console.tsx
kang ce27a3ec8a feat(admin, i18n): enhance admin dashboard and user management with new features and translations
Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
2026-06-03 10:07:51 +08:00

1034 lines
39 KiB
TypeScript

"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 <span className="text-muted-foreground">{empty}</span>;
}
const copy = async (e: React.MouseEvent): Promise<void> => {
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 (
<button
type="button"
className="group inline-flex min-w-0 w-full max-w-full items-center gap-1 rounded-md border border-transparent px-0.5 py-0.5 text-left font-mono text-xs transition-colors hover:border-border hover:bg-muted/60"
title={value}
aria-label={copyHint ?? t("copyTxnNo")}
onClick={(e) => void copy(e)}
>
<span className="min-w-0 flex-1 truncate">{value}</span>
<Copy
className="size-3.5 shrink-0 text-muted-foreground opacity-60 group-hover:opacity-100"
aria-hidden
/>
</button>
);
}
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 (
<AdminRowActionsMenu
busy={busy}
ariaLabel={t("actionsMenuAriaLabel")}
actions={[
{
key: "complete",
label: t("completeCredit"),
hidden: !canCompleteTransferInCredit(row, canWriteWallet),
onClick: () => 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<AdminTransferOrderListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [draft, setDraft] = useState<TransferFilters>(emptyTransferFilters);
const [applied, setApplied] = useState<TransferFilters>(emptyTransferFilters);
const [actionLoading, setActionLoading] = useState<Set<string>>(new Set());
const doAction = async (
transferNo: string,
fn: () => Promise<unknown>,
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 (
<>
<Card>
<CardHeader>
<CardTitle>{t("transferOrders")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="to-transfer-no">{t("localTransferNo")}</Label>
<Input
id="to-transfer-no"
placeholder={t("search")}
value={draft.transferNo}
onChange={(e) => setDraft((d) => ({ ...d, transferNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-ext">{t("externalRefNo")}</Label>
<Input
id="to-ext"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-account">{t("playerAccount")}</Label>
<Input
id="to-account"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-player">{t("playerId")}</Label>
<Input
id="to-player"
inputMode="numeric"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="to-created-range"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="to-status">{t("status")}</Label>
<Select
modal={false}
value={
draft.statusCsv === "" || !TRANSFER_ORDER_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="to-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, TRANSFER_ORDER_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{TRANSFER_ORDER_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
onCheckedChange={(v) =>
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnly")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<AdminTableExportButton
tableId="wallet-transfer-orders-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="rounded-md border">
<Table id="wallet-transfer-orders-table" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead className="w-14">{t("direction")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 max-w-[14rem]">{t("failReason")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
<TableHead className="sticky right-0 z-20 bg-muted w-12 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">{t("table.actions", { ns: "common" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={13} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.transfer_no} copyHint={t("copyTransferNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell>{row.direction}</TableCell>
<TableCell className="tabular-nums">
{formatAdminMinorUnits(row.amount, row.currency_code)}
</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
</TableCell>
<TableCell className="max-w-[14rem] whitespace-normal break-words text-xs text-muted-foreground">
{row.fail_reason?.trim() ? row.fail_reason : "—"}
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.finished_at)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-center align-middle shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<div className="flex justify-center">
<TransferOrderRowActions
row={row}
canWriteWallet={canWriteWallet}
busy={actionLoading.has(row.transfer_no)}
onCompleteCredit={handleCompleteCredit}
onReverse={handleReverse}
onManualProcess={handleManuallyProcess}
t={t}
/>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId="wallet-transfer-orders-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
<ConfirmDialog />
</>
);
}
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<AdminWalletTxnListData | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
const [applied, setApplied] = useState<TxnFilters>(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 (
<Card>
<CardHeader>
<CardTitle>{t("walletTransactions")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="tx-no">{t("txnNo")}</Label>
<Input
id="tx-no"
placeholder={t("search")}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-ext">{t("externalRefNo")}</Label>
<Input
id="tx-ext"
placeholder={t("search")}
value={draft.externalRefNo}
onChange={(e) => setDraft((d) => ({ ...d, externalRefNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-account">{t("playerAccount")}</Label>
<Input
id="tx-account"
placeholder={t("playerAccountPlaceholder")}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-player">{t("playerId")}</Label>
<Input
id="tx-player"
inputMode="numeric"
placeholder={t("playerIdOptional")}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-biz">{t("bizType")}</Label>
<Select
modal={false}
value={
draft.bizType === "" || !WALLET_TXN_BIZ_OPTIONS.some((o) => o.value === draft.bizType)
? WALLET_FILTER_ALL
: draft.bizType
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-biz" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_BIZ_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_BIZ_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="tx-status">{t("status")}</Label>
<Select
modal={false}
value={
draft.statusCsv === "" || !WALLET_TXN_STATUS_OPTIONS.some((o) => o.value === draft.statusCsv)
? WALLET_FILTER_ALL
: draft.statusCsv
}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusCsv: v == null || v === WALLET_FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="tx-status" className="h-8 w-full">
<SelectValue>
{(v) => walletAdminSelectDisplayedLabel(v, WALLET_TXN_STATUS_OPTIONS, t)}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={WALLET_FILTER_ALL}>{t("filterAll")}</SelectItem>
{WALLET_TXN_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-2 xl:col-span-2">
<AdminDateRangeField
id="tx-created-range"
label={t("requestDateRange")}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
<div className="flex flex-col justify-end gap-2 sm:col-span-2 lg:col-span-1">
<span className="text-sm font-medium leading-none">{t("options")}</span>
<label className="flex min-h-9 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={draft.abnormalOnly}
onCheckedChange={(v) =>
setDraft((d) => ({ ...d, abnormalOnly: v === true }))
}
/>
{t("abnormalOnlyPending")}
</label>
</div>
</div>
<div className="flex flex-wrap gap-2">
<AdminTableExportButton
tableId="wallet-transactions-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("search")}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="rounded-md border">
<Table id="wallet-transactions-table" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={11} />
) : !data || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={11} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell className="min-w-0 text-xs">{row.biz_type}</TableCell>
<TableCell className="tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? t("in") : t("out")})
</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.updated_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId="wallet-transactions-per-page"
total={data.total}
page={page}
lastPage={Math.max(1, Math.ceil(data.total / Math.max(1, data.per_page)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</>
) : null}
</CardContent>
</Card>
);
}
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<AdminPlayerWalletsData | null>(null);
const [err, setErr] = useState<string | null>(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 (
<Card>
<CardHeader>
<CardTitle>{t("playerWalletQuery")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-2">
<div className="grid gap-1.5">
<Label htmlFor="pw-id">{t("playerId")}</Label>
<Input
id="pw-id"
inputMode="numeric"
placeholder="1"
value={playerId}
onChange={(e) => setPlayerId(e.target.value)}
className="w-40"
/>
</div>
<AdminTableExportButton
tableId="player-wallet-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button type="button" onClick={() => void query()} disabled={loading}>
{loading ? t("querying") : t("query")}
</Button>
</div>
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{result ? (
<div className="space-y-3 rounded-lg border p-4 text-sm">
<p>
<span className="text-muted-foreground">{t("sitePlayer")}</span>{" "}
{result.player.site_code}:{result.player.site_player_id}
</p>
<Table id="player-wallet-table">
<TableHeader>
<TableRow>
<TableHead>{t("walletType")}</TableHead>
<TableHead>{t("currency")}</TableHead>
<TableHead>{t("balanceMinor")}</TableHead>
<TableHead>{t("availableBalance")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.wallets.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
{t("noWalletRows")}
</TableCell>
</TableRow>
) : (
result.wallets.map((w) => (
<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>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
) : null}
</CardContent>
</Card>
);
}