538 lines
21 KiB
TypeScript
538 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
getAdminReconcileJobItems,
|
|
getAdminReconcileJobs,
|
|
postAdminReconcileJob,
|
|
} from "@/api/admin-reconcile";
|
|
import { getAdminPlayers } from "@/api/admin-player";
|
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
import { cn } from "@/lib/utils";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
|
import type {
|
|
AdminReconcileItemsData,
|
|
AdminReconcileJobListData,
|
|
} from "@/types/api/admin-reconcile";
|
|
|
|
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
|
|
|
|
/** 与后端 reconcile_type 对齐;扩展时在 API 与下拉同步增加 */
|
|
const RECONCILE_TYPE = "wallet_transfer" as const;
|
|
|
|
function jobStatusLabel(status: string, t: (key: string) => string): string {
|
|
switch (status) {
|
|
case "completed":
|
|
return t("statusCompleted");
|
|
case "running":
|
|
return t("statusRunning");
|
|
case "failed":
|
|
return t("statusFailed");
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
function itemStatusLabel(status: string, t: (key: string) => string): string {
|
|
switch (status) {
|
|
case "mismatch":
|
|
return t("itemMismatch");
|
|
case "matched":
|
|
return t("itemMatched");
|
|
case "pending_check":
|
|
return t("itemPendingCheck");
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
|
|
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
|
switch (type) {
|
|
case "wallet_transfer":
|
|
return t("reconcileTypeWalletTransfer");
|
|
default:
|
|
return type;
|
|
}
|
|
}
|
|
|
|
export function ReconcileConsole(): React.ReactElement {
|
|
const { t } = useTranslation(["reconcile", "common"]);
|
|
const profile = useAdminProfile();
|
|
const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]);
|
|
const formatTs = useAdminDateTimeFormatter();
|
|
|
|
const [jobs, setJobs] = useState<AdminReconcileJobListData | null>(null);
|
|
const [jobsLoading, setJobsLoading] = useState(true);
|
|
const [jobsErr, setJobsErr] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(10);
|
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
|
|
const [itemsPage, setItemsPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
const [itemsLoading, setItemsLoading] = useState(false);
|
|
|
|
const [dateFrom, setDateFrom] = useState("");
|
|
const [dateTo, setDateTo] = useState("");
|
|
const [playerSearch, setPlayerSearch] = useState("");
|
|
const [playerResults, setPlayerResults] = useState<AdminPlayerRow[]>([]);
|
|
const [playerLoading, setPlayerLoading] = useState(false);
|
|
const [selectedPlayer, setSelectedPlayer] = useState<AdminPlayerRow | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
const loadJobs = useCallback(async () => {
|
|
setJobsLoading(true);
|
|
setJobsErr(null);
|
|
try {
|
|
const d = await getAdminReconcileJobs({ page, per_page: perPage });
|
|
setJobs(d);
|
|
} catch (e) {
|
|
setJobsErr(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
|
setJobs(null);
|
|
} finally {
|
|
setJobsLoading(false);
|
|
}
|
|
}, [page, perPage, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadJobs();
|
|
});
|
|
}, [loadJobs]);
|
|
|
|
const loadItems = useCallback(async () => {
|
|
if (selectedId == null) {
|
|
setItems(null);
|
|
return;
|
|
}
|
|
setItemsLoading(true);
|
|
try {
|
|
const d = await getAdminReconcileJobItems(selectedId, {
|
|
page: itemsPage,
|
|
per_page: itemsPerPage,
|
|
});
|
|
setItems(d);
|
|
} catch (e) {
|
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadItemsFailed"));
|
|
setItems(null);
|
|
} finally {
|
|
setItemsLoading(false);
|
|
}
|
|
}, [selectedId, itemsPage, itemsPerPage, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void loadItems();
|
|
});
|
|
}, [loadItems]);
|
|
|
|
const loadPlayers = useCallback(async (keyword: string) => {
|
|
const q = keyword.trim();
|
|
if (q === "") {
|
|
setPlayerResults([]);
|
|
return;
|
|
}
|
|
setPlayerLoading(true);
|
|
try {
|
|
const data = await getAdminPlayers({ page: 1, per_page: 8, keyword: q });
|
|
setPlayerResults(data.items);
|
|
} catch {
|
|
setPlayerResults([]);
|
|
} finally {
|
|
setPlayerLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const q = playerSearch.trim();
|
|
if (q === "") {
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
void loadPlayers(q);
|
|
}, 250);
|
|
return () => window.clearTimeout(timer);
|
|
}, [loadPlayers, playerSearch]);
|
|
|
|
async function onCreate(): Promise<void> {
|
|
if (!dateFrom.trim() || !dateTo.trim()) {
|
|
toast.error(t("periodRequired"));
|
|
return;
|
|
}
|
|
if (dateFrom > dateTo) {
|
|
toast.error(t("periodOrderInvalid"));
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
await postAdminReconcileJob({
|
|
reconcile_type: RECONCILE_TYPE,
|
|
date_from: dateFrom,
|
|
date_to: dateTo,
|
|
player_id: selectedPlayer ? selectedPlayer.id : null,
|
|
});
|
|
toast.success(t("createSuccess"));
|
|
setPage(1);
|
|
setDateFrom("");
|
|
setDateTo("");
|
|
setPlayerSearch("");
|
|
setSelectedPlayer(null);
|
|
setPlayerResults([]);
|
|
await loadJobs();
|
|
} catch (e) {
|
|
toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed"));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const jm = jobs?.meta;
|
|
const im = items?.meta;
|
|
const selectedJob = jobs?.items.find((job) => job.id === selectedId) ?? null;
|
|
|
|
return (
|
|
<div className="flex w-full max-w-none flex-col gap-6">
|
|
{canCreate ? (
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header">
|
|
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content pt-4">
|
|
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(220px,0.95fr)_auto] lg:items-end">
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
|
<Input id="rc-type" value={t("reconcileTypeFixed")} readOnly className="bg-muted/30" />
|
|
</div>
|
|
<div className="grid gap-1.5">
|
|
<AdminDateRangeField
|
|
id="rc-date-range"
|
|
label={t("dateRange")}
|
|
from={dateFrom}
|
|
to={dateTo}
|
|
onRangeChange={({ from, to }) => {
|
|
setDateFrom(from);
|
|
setDateTo(to);
|
|
}}
|
|
/>
|
|
</div>
|
|
<Button type="button" className="w-full lg:w-auto" onClick={() => void onCreate()} disabled={submitting}>
|
|
{submitting ? t("submitting") : t("createTask")}
|
|
</Button>
|
|
</div>
|
|
<div className="grid gap-1.5 pt-4">
|
|
<Label htmlFor="rc-player-search">{t("playerSearch")}</Label>
|
|
<Input
|
|
id="rc-player-search"
|
|
value={playerSearch}
|
|
onChange={(e) => setPlayerSearch(e.target.value)}
|
|
placeholder={t("playerSearchPlaceholder")}
|
|
/>
|
|
{selectedPlayer ? (
|
|
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-foreground">
|
|
{selectedPlayer.site_player_id}
|
|
{selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
|
|
{selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSelectedPlayer(null);
|
|
setPlayerSearch("");
|
|
setPlayerResults([]);
|
|
}}
|
|
>
|
|
{t("playerClear")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
{playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
|
|
<div className="rounded-lg border bg-background">
|
|
<div className="max-h-56 overflow-y-auto">
|
|
{playerLoading ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t("loadingPlayers")}</div>
|
|
) : playerResults.length === 0 ? (
|
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t("playerNoResults")}</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{playerResults.map((player) => {
|
|
const active = selectedPlayer?.id === player.id;
|
|
return (
|
|
<button
|
|
key={player.id}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-start justify-between gap-3 px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted/25",
|
|
active && "bg-muted/30",
|
|
)}
|
|
onClick={() => {
|
|
setSelectedPlayer(player);
|
|
setPlayerSearch(player.site_player_id);
|
|
}}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate font-medium text-foreground">
|
|
{player.site_player_id}
|
|
{player.nickname ? ` · ${player.nickname}` : ""}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{player.username ?? "—"} · {player.site_code}
|
|
</div>
|
|
</div>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{active ? t("playerSelectedShort") : t("playerChoose")}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
|
|
)}
|
|
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
|
<div>
|
|
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
|
</div>
|
|
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
|
{t("refresh")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content pt-4">
|
|
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
|
{jobsLoading && !jobs ? (
|
|
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
|
) : null}
|
|
{jobs ? (
|
|
<>
|
|
<div className="admin-table-shell">
|
|
<Table id="reconcile-jobs-table">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="sticky left-0 z-20 w-24 bg-muted/20 shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
|
{t("table.id", { ns: "common" })}
|
|
</TableHead>
|
|
<TableHead className="sticky left-24 z-20 min-w-[14rem] bg-muted/20 shadow-[1px_0_0_rgba(203,213,225,0.7)]">
|
|
{t("jobNo")}
|
|
</TableHead>
|
|
<TableHead>{t("type")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
<TableHead>{t("period")}</TableHead>
|
|
<TableHead>{t("createdAt")}</TableHead>
|
|
<TableHead className="sticky right-0 z-20 w-28 bg-muted/20 text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
|
{t("operate")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{jobs.items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-muted-foreground">
|
|
{t("states.noData", { ns: "common" })}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
jobs.items.map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell className="sticky left-0 z-10 bg-card tabular-nums shadow-[1px_0_0_rgba(226,232,240,0.9)]">
|
|
{row.id}
|
|
</TableCell>
|
|
<TableCell className="sticky left-24 z-10 min-w-[14rem] bg-card font-mono text-xs shadow-[1px_0_0_rgba(226,232,240,0.9)]">
|
|
{row.job_no}
|
|
</TableCell>
|
|
<TableCell className="text-sm">{reconcileTypeLabel(row.reconcile_type, t)}</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={row.status}>
|
|
{jobStatusLabel(row.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="max-w-[16rem] text-xs text-muted-foreground">
|
|
<span className="line-clamp-2">
|
|
{row.period_start ? formatTs(row.period_start) : "—"} ~{" "}
|
|
{row.period_end ? formatTs(row.period_end) : "—"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
|
{formatTs(row.created_at)}
|
|
</TableCell>
|
|
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(226,232,240,0.9)]">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSelectedId(row.id);
|
|
setItemsPage(1);
|
|
setDetailOpen(true);
|
|
}}
|
|
>
|
|
{t("view")}
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{jm ? (
|
|
<AdminListPaginationFooter
|
|
selectId="reconcile-jobs-per-page"
|
|
total={jm.total}
|
|
page={jm.current_page}
|
|
lastPage={Math.max(1, jm.last_page)}
|
|
perPage={jm.per_page}
|
|
loading={jobsLoading}
|
|
onPerPageChange={(n) => {
|
|
setPerPage(n);
|
|
setPage(1);
|
|
}}
|
|
onPageChange={setPage}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog
|
|
open={detailOpen}
|
|
onOpenChange={(open) => {
|
|
setDetailOpen(open);
|
|
if (!open) {
|
|
setSelectedId(null);
|
|
setItems(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton
|
|
className="flex h-[min(86vh,780px)] !max-w-[min(920px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0"
|
|
>
|
|
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
|
<DialogTitle className="text-base">{t("detailsTitle")}</DialogTitle>
|
|
<DialogDescription className="font-mono text-xs">
|
|
{selectedJob ? `${selectedJob.job_no} · #${selectedJob.id}` : selectedId != null ? `#${selectedId}` : ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
|
{itemsLoading && !items ? (
|
|
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
) : null}
|
|
{items ? (
|
|
<>
|
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
<span>{t("jobNo")} {items.job_no}</span>
|
|
<span>·</span>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
{t("status")}
|
|
{selectedJob ? (
|
|
<AdminStatusBadge status={selectedJob.status}>
|
|
{jobStatusLabel(selectedJob.status, t)}
|
|
</AdminStatusBadge>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</span>
|
|
<span>·</span>
|
|
<span>{t("period")} {selectedJob ? `${selectedJob.period_start ? formatTs(selectedJob.period_start) : "—"} ~ ${selectedJob.period_end ? formatTs(selectedJob.period_end) : "—"}` : "—"}</span>
|
|
</div>
|
|
<div className="rounded-lg border bg-background">
|
|
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
|
<TableHead>{t("sideARef")}</TableHead>
|
|
<TableHead>{t("sideBRef")}</TableHead>
|
|
<TableHead>{t("differenceAmount")}</TableHead>
|
|
<TableHead>{t("status")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-muted-foreground">
|
|
{t("noDetails")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
items.items.map((r) => (
|
|
<TableRow key={r.id}>
|
|
<TableCell>{r.id}</TableCell>
|
|
<TableCell className="font-mono text-xs">{r.side_a_ref ?? "—"}</TableCell>
|
|
<TableCell className="font-mono text-xs">{r.side_b_ref ?? "—"}</TableCell>
|
|
<TableCell className="tabular-nums">{r.difference_amount}</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={r.status}>
|
|
{itemStatusLabel(r.status, t)}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
{im ? (
|
|
<div className="pt-4">
|
|
<AdminListPaginationFooter
|
|
selectId="reconcile-items-per-page"
|
|
total={im.total}
|
|
page={im.current_page}
|
|
lastPage={Math.max(1, im.last_page)}
|
|
perPage={im.per_page}
|
|
loading={itemsLoading}
|
|
onPerPageChange={(n) => {
|
|
setItemsPerPage(n);
|
|
setItemsPage(1);
|
|
}}
|
|
onPageChange={setItemsPage}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|