383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuCheckboxItem,
|
||
DropdownMenuContent,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
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 { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||
import { ChevronDown } from "lucide-react";
|
||
|
||
const TICKET_STATUS_OPTIONS = [
|
||
"pending_confirm",
|
||
"partial_pending_confirm",
|
||
"success",
|
||
"failed",
|
||
"pending_payout",
|
||
"settled_win",
|
||
"settled_lose",
|
||
] as const;
|
||
|
||
type TicketFilters = {
|
||
playerQuery: string;
|
||
drawNo: string;
|
||
numberKeyword: string;
|
||
startDate: string;
|
||
endDate: string;
|
||
statuses: string[];
|
||
};
|
||
|
||
const emptyTicketFilters: TicketFilters = {
|
||
playerQuery: "",
|
||
drawNo: "",
|
||
numberKeyword: "",
|
||
startDate: "",
|
||
endDate: "",
|
||
statuses: [],
|
||
};
|
||
|
||
function ticketStatusText(value: string, t: (key: string) => string): string {
|
||
const key = `statusOptions.${value}`;
|
||
const translated = t(key);
|
||
return translated === key ? value : translated;
|
||
}
|
||
|
||
function ticketStatusSummary(statuses: string[], t: (key: string) => string): string {
|
||
if (statuses.length === 0) {
|
||
return t("statusOptions.all");
|
||
}
|
||
|
||
if (statuses.length === 1) {
|
||
return ticketStatusText(statuses[0], t);
|
||
}
|
||
|
||
return t("statusSelectedCount", { count: statuses.length, defaultValue: `已选 ${statuses.length} 项` });
|
||
}
|
||
|
||
function ticketStatusVariant(
|
||
value: string,
|
||
): "default" | "secondary" | "destructive" | "outline" {
|
||
if (value === "settled_win") return "secondary";
|
||
if (value === "failed") return "destructive";
|
||
if (value === "pending_payout") return "default";
|
||
return "outline";
|
||
}
|
||
|
||
export function PlayerTicketsConsole(): React.ReactElement {
|
||
const { t } = useTranslation(["tickets", "common"]);
|
||
const formatTs = useAdminDateTimeFormatter();
|
||
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
|
||
const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
|
||
const [data, setData] = useState<AdminTicketItemsData | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
const [perPage, setPerPage] = useState(20);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setErr(null);
|
||
try {
|
||
const playerQuery = applied.playerQuery.trim();
|
||
const playerId = Number(playerQuery);
|
||
const query =
|
||
playerQuery === ""
|
||
? {}
|
||
: Number.isInteger(playerId) && playerId > 0 && String(playerId) === playerQuery
|
||
? { player_id: playerId }
|
||
: { player_account: playerQuery };
|
||
|
||
const d = await getAdminTicketItems({
|
||
page,
|
||
per_page: perPage,
|
||
...query,
|
||
draw_no: applied.drawNo.trim() || undefined,
|
||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||
number: applied.numberKeyword.trim() || undefined,
|
||
start_date: applied.startDate || undefined,
|
||
end_date: applied.endDate || undefined,
|
||
});
|
||
setData(d);
|
||
} catch (e) {
|
||
setErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||
setData(null);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [applied, page, perPage, t]);
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
void load();
|
||
});
|
||
}, [load]);
|
||
|
||
const runSearch = () => {
|
||
setErr(null);
|
||
setApplied({
|
||
...draft,
|
||
playerQuery: draft.playerQuery.trim(),
|
||
drawNo: draft.drawNo.trim(),
|
||
numberKeyword: draft.numberKeyword.trim(),
|
||
});
|
||
setPage(1);
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setDraft(emptyTicketFilters);
|
||
setApplied(emptyTicketFilters);
|
||
setErr(null);
|
||
setPage(1);
|
||
};
|
||
|
||
const toggleStatus = (status: string, checked: boolean) => {
|
||
setDraft((current) => ({
|
||
...current,
|
||
statuses: checked
|
||
? [...current.statuses, status]
|
||
: current.statuses.filter((item) => item !== status),
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<Card className="admin-list-card w-full max-w-none">
|
||
<CardHeader className="admin-list-header">
|
||
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="admin-list-content">
|
||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="pt-player">{t("playerId")}</Label>
|
||
<Input
|
||
id="pt-player"
|
||
className="font-mono"
|
||
placeholder={t("playerIdPlaceholder")}
|
||
value={draft.playerQuery}
|
||
onChange={(e) =>
|
||
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="pt-draw">{t("drawNoOptional")}</Label>
|
||
<Input
|
||
id="pt-draw"
|
||
className="font-mono text-sm"
|
||
placeholder={t("drawNoPlaceholder")}
|
||
value={draft.drawNo}
|
||
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="pt-number">{t("numberKeyword")}</Label>
|
||
<Input
|
||
id="pt-number"
|
||
className="font-mono text-sm"
|
||
placeholder={t("numberKeywordPlaceholder")}
|
||
value={draft.numberKeyword}
|
||
onChange={(e) =>
|
||
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<AdminDateRangeField
|
||
id="pt-date-range"
|
||
label={t("placedDateRange")}
|
||
from={draft.startDate}
|
||
to={draft.endDate}
|
||
onRangeChange={(range) =>
|
||
setDraft((current) => ({
|
||
...current,
|
||
startDate: range.from,
|
||
endDate: range.to,
|
||
}))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="text-sm font-medium leading-none">{t("statusFilterLabel")}</span>
|
||
<span className="text-muted-foreground text-xs">{t("statusHint")}</span>
|
||
</div>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger className="inline-flex h-11 w-full items-center justify-between rounded-md border border-border bg-card px-4 text-left text-sm font-normal text-primary shadow-sm outline-none transition-all hover:bg-accent hover:text-primary focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50">
|
||
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
|
||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent className="w-[min(28rem,calc(100vw-2rem))]">
|
||
{TICKET_STATUS_OPTIONS.map((status) => (
|
||
<DropdownMenuCheckboxItem
|
||
key={status}
|
||
checked={draft.statuses.includes(status)}
|
||
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
|
||
>
|
||
{ticketStatusText(status, t)}
|
||
</DropdownMenuCheckboxItem>
|
||
))}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<AdminTableExportButton
|
||
tableId="tickets-table"
|
||
filename="注单列表"
|
||
sheetName="注单列表"
|
||
/>
|
||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||
{t("query")}
|
||
</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>
|
||
|
||
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
|
||
<p className="text-muted-foreground text-sm">
|
||
{applied.playerQuery ? (
|
||
<>
|
||
{t("playerId")}:<span className="font-mono">{applied.playerQuery}</span>
|
||
</>
|
||
) : (
|
||
<span>{t("allTickets", { defaultValue: "全部注单" })}</span>
|
||
)}
|
||
{applied.drawNo ? (
|
||
<>
|
||
{" · "}
|
||
{t("drawNo")}:<span className="font-mono">{applied.drawNo}</span>
|
||
</>
|
||
) : null}
|
||
</p>
|
||
) : null}
|
||
|
||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||
{loading ? (
|
||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||
) : null}
|
||
|
||
{data ? (
|
||
<>
|
||
<div className="admin-table-shell">
|
||
<Table id="tickets-table">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>{t("ticketNo")}</TableHead>
|
||
<TableHead>{t("player")}</TableHead>
|
||
<TableHead>{t("orderNo")}</TableHead>
|
||
<TableHead>{t("drawNo")}</TableHead>
|
||
<TableHead>{t("playCode")}</TableHead>
|
||
<TableHead>{t("number")}</TableHead>
|
||
<TableHead className="text-right">{t("betAmount")}</TableHead>
|
||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
||
<TableHead>{t("status")}</TableHead>
|
||
<TableHead>{t("failReason")}</TableHead>
|
||
<TableHead className="text-right">{t("winAmount")}</TableHead>
|
||
<TableHead>{t("placedAt")}</TableHead>
|
||
<TableHead>{t("updatedAt")}</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.items.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={13} className="text-muted-foreground">
|
||
{t("states.noData", { ns: "common" })}
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
data.items.map((row) => {
|
||
const winLabel = row.jackpot_win_amount > 0
|
||
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
|
||
: row.win_amount_formatted;
|
||
|
||
return (
|
||
<TableRow key={row.ticket_no}>
|
||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||
<TableCell className="text-xs">
|
||
<div className="flex flex-col leading-tight">
|
||
<span className="font-medium">
|
||
{row.nickname ?? row.username ?? "—"}
|
||
</span>
|
||
<span className="font-mono text-[11px] text-muted-foreground">
|
||
{row.site_code && row.site_player_id
|
||
? `${row.site_code} / ${row.site_player_id}`
|
||
: row.site_player_id ?? `#${row.player_id}`}
|
||
</span>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||
<TableCell className="text-xs">{row.play_code}</TableCell>
|
||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||
<TableCell className="text-right tabular-nums text-xs">
|
||
{row.total_bet_amount_formatted}
|
||
</TableCell>
|
||
<TableCell className="text-right tabular-nums text-xs">
|
||
{row.actual_deduct_amount_formatted}
|
||
</TableCell>
|
||
<TableCell className="text-xs">
|
||
<Badge variant={ticketStatusVariant(row.status)}>
|
||
{ticketStatusText(row.status, t)}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||
</TableCell>
|
||
<TableCell className="text-right tabular-nums text-xs">{winLabel}</TableCell>
|
||
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
|
||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||
</TableRow>
|
||
);
|
||
})
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<AdminListPaginationFooter
|
||
selectId="player-tickets-per-page"
|
||
total={data.total}
|
||
page={data.page}
|
||
lastPage={Math.max(1, data.last_page)}
|
||
perPage={data.per_page}
|
||
loading={loading}
|
||
onPerPageChange={(n) => {
|
||
setPerPage(n);
|
||
setPage(1);
|
||
}}
|
||
onPageChange={setPage}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|