Files
lotteryAdmin/src/modules/tickets/player-tickets-console.tsx

383 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}