903 lines
34 KiB
TypeScript
903 lines
34 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { Copy } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getAdminPlayerWallets,
|
|
getAdminTransferOrders,
|
|
getAdminWalletTransactions,
|
|
reverseTransferOrder,
|
|
manuallyProcessTransferOrder,
|
|
} from "@/api/admin-wallet";
|
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } 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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type {
|
|
AdminPlayerWalletsData,
|
|
AdminTransferOrderListData,
|
|
AdminWalletTxnListData,
|
|
} from "@/types/api/admin-wallet";
|
|
|
|
function formatMinorUnits(minor: number, currencyCode: string): string {
|
|
const major = minor / 100;
|
|
return `${major.toFixed(2)} ${currencyCode}`;
|
|
}
|
|
|
|
/** 长单号/流水号:单行截断;点击复制全文,悬停可看全文 */
|
|
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 statusBadgeVariant(
|
|
status: string,
|
|
): "default" | "secondary" | "destructive" | "outline" {
|
|
if (status === "success" || status === "posted") return "secondary";
|
|
if (status === "failed") return "destructive";
|
|
if (status === "pending_reconcile") return "outline";
|
|
if (status === "reversed" || status === "manually_processed") return "outline";
|
|
return "default";
|
|
}
|
|
|
|
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("statusManuallyProcessed");
|
|
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: "statusManuallyProcessed" },
|
|
];
|
|
|
|
/** 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;
|
|
}
|
|
|
|
export function TransferOrdersPanel(): React.ReactElement {
|
|
const { t } = useTranslation(["wallet", "common"]);
|
|
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(20);
|
|
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) =>
|
|
doAction(transferNo, () => reverseTransferOrder(transferNo), t("reverseSuccess"));
|
|
|
|
const handleManuallyProcess = (transferNo: string) =>
|
|
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess"));
|
|
|
|
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 : t("loadFailed"));
|
|
setData(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, perPage, applied, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
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">
|
|
<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 ? (
|
|
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
) : null}
|
|
|
|
{data ? (
|
|
<>
|
|
<div className="rounded-md border">
|
|
<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>
|
|
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
|
|
<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="w-24">{t("actions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={10} 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>
|
|
<TableCell className="text-xs">
|
|
#{row.player_id}
|
|
<br />
|
|
<span className="text-muted-foreground">
|
|
{row.site_player_id ?? row.username ?? "—"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>{row.direction}</TableCell>
|
|
<TableCell className="tabular-nums">
|
|
{formatMinorUnits(row.amount, row.currency_code)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant={statusBadgeVariant(row.status)}>{statusLabelT(row.status, t)}</Badge>
|
|
</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>
|
|
{row.status === "pending_reconcile" ? (
|
|
<div className="flex flex-col gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={actionLoading.has(row.transfer_no)}
|
|
onClick={() => handleReverse(row.transfer_no)}
|
|
>
|
|
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={actionLoading.has(row.transfer_no)}
|
|
onClick={() => handleManuallyProcess(row.transfer_no)}
|
|
>
|
|
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<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}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function WalletTxnsPanel(): React.ReactElement {
|
|
const { t } = useTranslation(["wallet", "common"]);
|
|
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(20);
|
|
const [draft, setDraft] = useState<TxnFilters>(emptyTxnFilters);
|
|
const [applied, setApplied] = useState<TxnFilters>(emptyTxnFilters);
|
|
|
|
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 : t("loadFailed"));
|
|
setData(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, perPage, applied, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
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">
|
|
<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 ? (
|
|
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
) : null}
|
|
|
|
{data ? (
|
|
<>
|
|
<div className="rounded-md border">
|
|
<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>
|
|
<TableHead className="whitespace-nowrap">{t("playerAccount")}</TableHead>
|
|
<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>
|
|
{data.items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} 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>
|
|
<TableCell className="min-w-0 text-xs">
|
|
#{row.player_id}
|
|
<br />
|
|
<span className="text-muted-foreground">
|
|
{row.site_player_id ?? row.username ?? "—"}
|
|
</span>
|
|
</TableCell>
|
|
<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>
|
|
<Badge variant={statusBadgeVariant(row.status)}>{statusLabelT(row.status, t)}</Badge>
|
|
</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>
|
|
<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}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function PlayerWalletPanel(): React.ReactElement {
|
|
const { t } = useTranslation(["wallet", "common"]);
|
|
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(t("invalidPlayerId"));
|
|
setResult(null);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setErr(null);
|
|
try {
|
|
const d = await getAdminPlayerWallets(id);
|
|
setResult(d);
|
|
} catch (e) {
|
|
setErr(e instanceof LotteryApiBizError ? e.message : t("queryFailed"));
|
|
setResult(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [playerId, t]);
|
|
|
|
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>
|
|
<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>
|
|
<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">
|
|
{formatMinorUnits(w.available_balance, w.currency_code)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|