Files
lotteryAdmin/src/modules/draws/draws-index-console.tsx

313 lines
11 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 Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListData, AdminDrawListItem } from "@/types/api/admin-draws";
import { PRD_DRAW_RESULT_MANAGE } from "./draw-prd";
import { DrawStatusBadge } from "./draw-status-badge";
/** 下拉「不限」;请求时不传 status */
const DRAW_FILTER_ALL = "__all__";
/** 与 {@see App\Lottery\DrawStatus} 一致 */
const DRAW_STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "pending", label: "statusOptions.pending" },
{ value: "open", label: "statusOptions.open" },
{ value: "closing", label: "statusOptions.closing" },
{ value: "closed", label: "statusOptions.closed" },
{ value: "drawing", label: "statusOptions.drawing" },
{ value: "review", label: "statusOptions.review" },
{ value: "cooldown", label: "statusOptions.cooldown" },
{ value: "settling", label: "statusOptions.settling" },
{ value: "settled", label: "statusOptions.settled" },
{ value: "cancelled", label: "statusOptions.cancelled" },
];
function drawAdminStatusSelectLabel(raw: unknown, t: (key: string) => string): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === DRAW_FILTER_ALL) {
return t("statusOptions.all");
}
const key = DRAW_STATUS_OPTIONS.find((o) => o.value === v)?.label;
return key ? t(key) : v;
}
export function DrawsIndexConsole() {
const { t } = useTranslation(["draws", "common"]);
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const canManageDraw = adminHasAnyPermission(profile?.permissions, [PRD_DRAW_RESULT_MANAGE]);
const [data, setData] = useState<AdminDrawListData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState("");
const [draftStatus, setDraftStatus] = useState("");
const [appliedDrawNo, setAppliedDrawNo] = useState("");
const [appliedStatus, setAppliedStatus] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState<number>(20);
const [generating, setGenerating] = useState(false);
const drawStatusTriggerLabel = useMemo(
() =>
drawAdminStatusSelectLabel(
draftStatus === "" || !DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
? DRAW_FILTER_ALL
: draftStatus,
t,
),
[draftStatus, t],
);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const d = await getAdminDraws({
page,
per_page: perPage,
draw_no: appliedDrawNo.trim() || undefined,
status:
appliedStatus.trim() === "" || appliedStatus === DRAW_FILTER_ALL
? undefined
: appliedStatus.trim(),
});
setData(d);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : t("loadFailed");
setError(msg);
setData(null);
} finally {
setLoading(false);
}
}, [page, perPage, appliedDrawNo, appliedStatus, t]);
async function generatePlan(): Promise<void> {
setGenerating(true);
try {
const res = await postAdminGenerateDrawPlan();
toast.success(
t("generateSuccess", {
created: res.created,
upcoming: res.upcoming,
target: res.buffer_target,
}),
);
await load();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("generateFailed"));
} finally {
setGenerating(false);
}
}
useEffect(() => {
const timer = window.setTimeout(() => {
void load();
}, 0);
return () => window.clearTimeout(timer);
}, [load]);
return (
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="text-lg">{t("statusListTitle")}</CardTitle>
{canManageDraw ? (
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
{generating ? t("generating") : t("generatePlan")}
</Button>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{/* Grid桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}
<div
className="grid max-w-full gap-x-6 gap-y-3 sm:grid-cols-[minmax(0,12rem)_minmax(0,11rem)_auto] sm:gap-y-1.5"
>
<Label htmlFor="draw-filter-no">
{t("drawNo")}
</Label>
<Input
id="draw-filter-no"
placeholder={t("fuzzyDrawNo")}
value={draftDrawNo}
className="w-full min-w-0 sm:w-full"
onChange={(e) => setDraftDrawNo(e.target.value)}
/>
<Label htmlFor="draw-filter-status">
{t("status")}
</Label>
<div className="min-w-0">
<Select
modal={false}
value={
draftStatus === "" ||
!DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
? DRAW_FILTER_ALL
: draftStatus
}
onValueChange={(v) =>
setDraftStatus(v == null || v === DRAW_FILTER_ALL ? "" : String(v))
}
>
<SelectTrigger id="draw-filter-status" className="h-8 w-full min-w-0 sm:w-44">
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
<SelectItem value={DRAW_FILTER_ALL}>{t("statusOptions.all")}</SelectItem>
{DRAW_STATUS_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="[grid-area:act] flex flex-wrap gap-2">
<Button
type="button"
onClick={() => {
setAppliedDrawNo(draftDrawNo);
setAppliedStatus(draftStatus);
setPage(1);
}}
>
{t("queryDraw")}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDraftDrawNo("");
setDraftStatus("");
setAppliedDrawNo("");
setAppliedStatus("");
setPage(1);
}}
>
{t("reset")}
</Button>
</div>
</div>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : null}
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("startTime")}</TableHead>
<TableHead>{t("closeTime")}</TableHead>
<TableHead>{t("drawTime")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("betTotal")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("profitLoss")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
) : data === null || data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
data.items.map((row: AdminDrawListItem) => (
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.draw_no}</TableCell>
<TableCell className="text-sm">{formatDt(row.start_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.close_time)}</TableCell>
<TableCell className="text-sm">{formatDt(row.draw_time)}</TableCell>
<TableCell>
<DrawStatusBadge status={row.status} />
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{row.total_bet_minor ?? "—"}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{row.total_payout_minor ?? "—"}
</TableCell>
<TableCell
className={cn(
"text-right font-mono text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
)}
>
{row.profit_loss_minor ?? "—"}
</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/draws/${row.id}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{t("viewDetails")}
</Link>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{data ? (
<AdminListPaginationFooter
selectId="draw-list-per-page"
total={data.meta.total}
page={page}
lastPage={data.meta.last_page}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
) : null}
</CardContent>
</Card>
);
}