"use client"; import Link from "next/link"; import { ArrowUpRight, ChevronDown, ChevronRight, RefreshCw, Search } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { toast } from "sonner"; import { getAdminReconcileJobItems, getAdminReconcileJobs, postAdminReconcileJob, } from "@/api/admin-reconcile"; import { completeTransferInCredit, manuallyProcessTransferOrder, reverseTransferOrder, } from "@/api/admin-wallet"; import { getAdminPlayers } from "@/api/admin-player"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { ReconcileJobDetailPanel, type ReconcileItemsFilter } from "@/modules/reconcile/reconcile-job-detail-panel"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_WALLET_WRITE_ANY } from "@/lib/admin-prd"; 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 { AdminReconcileJobRow, AdminReconcileItemsData, AdminReconcileJobListData, } from "@/types/api/admin-reconcile"; import { getJobSummaryValue, jobStatusLabel, renderPeriodRange, } from "@/modules/reconcile/reconcile-labels"; const MANAGE = ["prd.wallet_reconcile.manage"] as const; const RECONCILE_TYPE = "wallet_transfer" as const; export function ReconcileConsole(): React.ReactElement { const { t } = useTranslation(["reconcile", "common"]); const tRef = useTranslationRef(["reconcile", "common"]); const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const profile = useAdminProfile(); const canCreate = adminHasAnyPermission(profile?.permissions, [...MANAGE]); const canWriteWallet = adminHasAnyPermission(profile?.permissions, [...PRD_WALLET_WRITE_ANY]); const formatTs = useAdminDateTimeFormatter(); const [jobs, setJobs] = useState(null); const [jobsLoading, setJobsLoading] = useState(true); const [jobsErr, setJobsErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); const [selectedJob, setSelectedJob] = useState(null); const [items, setItems] = useState(null); const [itemsPage, setItemsPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(20); const [itemsLoading, setItemsLoading] = useState(false); const [itemsFilter, setItemsFilter] = useState("open"); const [scanOpen, setScanOpen] = useState(false); const [dateFrom, setDateFrom] = useState(""); const [dateTo, setDateTo] = useState(""); const [playerSearch, setPlayerSearch] = useState(""); const [playerResults, setPlayerResults] = useState([]); const [playerLoading, setPlayerLoading] = useState(false); const [playerPickerOpen, setPlayerPickerOpen] = useState(false); const [selectedPlayer, setSelectedPlayer] = useState(null); const [submitting, setSubmitting] = useState(false); const [actionBusy, setActionBusy] = 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 : tRef.current("loadFailed")); setJobs(null); } finally { setJobsLoading(false); } }, [page, perPage, tRef]); useAsyncEffect(() => { void loadJobs(); }, [page, perPage]); const loadItems = useCallback(async () => { if (selectedJob == null) { setItems(null); return; } setItemsLoading(true); try { const d = await getAdminReconcileJobItems(selectedJob.id, { page: itemsPage, per_page: itemsPerPage, }); setItems(d); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : tRef.current("loadItemsFailed")); setItems(null); } finally { setItemsLoading(false); } }, [selectedJob, itemsPage, itemsPerPage, tRef]); useAsyncEffect(() => { void loadItems(); }, [selectedJob, itemsPage, itemsPerPage]); 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(() => { if (!playerPickerOpen) { return; } const timer = window.setTimeout(() => { void loadPlayers(playerSearch); }, 250); return () => window.clearTimeout(timer); }, [loadPlayers, playerPickerOpen, playerSearch]); async function onCreate(): Promise { if (!dateFrom.trim() || !dateTo.trim()) { toast.error(t("periodRequired")); return; } if (dateFrom > dateTo) { toast.error(t("periodOrderInvalid")); return; } setSubmitting(true); try { const resp = await postAdminReconcileJob({ reconcile_type: RECONCILE_TYPE, date_from: dateFrom, date_to: dateTo, player_id: selectedPlayer ? selectedPlayer.id : null, }); const count = typeof resp.summary_json?.item_count === "number" ? resp.summary_json.item_count : resp.item_count ?? 0; toast.success(count > 0 ? t("createSuccess", { count }) : t("createSuccessEmpty")); setPage(1); setScanOpen(false); setDateFrom(""); setDateTo(""); setPlayerSearch(""); setSelectedPlayer(null); setPlayerResults([]); await loadJobs(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed")); } finally { setSubmitting(false); } } async function runTransferAction( transferNo: string, action: "reverse" | "complete_credit" | "manually_process", ): Promise { const confirmKey = action === "reverse" ? "reverse" : action === "complete_credit" ? "completeCredit" : "markCaseClosed"; const successKey = action === "reverse" ? "reverseSuccess" : action === "complete_credit" ? "completeCreditSuccess" : "markCaseClosedSuccess"; requestConfirm({ title: t(`confirm.${confirmKey}Title`, { ns: "wallet" }), description: t(`confirm.${confirmKey}Description`, { ns: "wallet", transferNo }), onConfirm: async () => { setActionBusy(true); try { if (action === "reverse") { await reverseTransferOrder(transferNo); } else if (action === "complete_credit") { await completeTransferInCredit(transferNo); } else { await manuallyProcessTransferOrder(transferNo); } toast.success(t(successKey, { ns: "wallet" })); await loadItems(); await loadJobs(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { ns: "wallet" })); } finally { setActionBusy(false); } }, }); } function openJobDetail(job: AdminReconcileJobRow): void { setSelectedJob(job); setItemsPage(1); setItemsFilter("open"); } function closeJobDetail(): void { setSelectedJob(null); setItems(null); } const jm = jobs?.meta; return (

{t("workflowHint")}

{t("shortcutAbnormalTransfers")}
{canCreate ? (
{scanOpen ? (
{ setDateFrom(from); setDateTo(to); }} />
setPlayerSearch(e.target.value)} placeholder={t("playerSearchPlaceholder")} /> } >
{playerLoading ? ( ) : playerResults.length === 0 ? (

{t("playerEmpty", { defaultValue: "无匹配玩家" })}

) : ( playerResults.map((player) => ( )) )}
{selectedPlayer ? (
{selectedPlayer.site_player_id} {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""}
) : null}
) : null}
) : (

{t("viewOnlyHint")}

)}

{t("jobsTitle")}

{jobsErr ?

{jobsErr}

: null} {jobs ? ( <> {t("jobNo")} {t("status")} {t("mismatchCount")} {t("period")} {t("finishedAt")} {t("operate")} {jobsLoading && jobs.items.length === 0 ? ( ) : jobs.items.length === 0 ? ( ) : ( jobs.items.map((row) => { const selected = selectedJob?.id === row.id; const mismatchCount = getJobSummaryValue(row.summary_json, "mismatch_count"); return ( {row.job_no} {jobStatusLabel(row.status, t)} 0 ? "font-medium text-amber-700" : "text-muted-foreground", )} > {mismatchCount} {renderPeriodRange(row, formatTs)} {formatTs(row.finished_at)} ); }) )}
{jm ? (
{ setPerPage(n); setPage(1); }} onPageChange={setPage} />
) : null} ) : jobsLoading ? ( ) : null}
{ if (!open) { closeJobDetail(); } }} > {selectedJob ? ( void runTransferAction(transferNo, "complete_credit")} onReverse={(transferNo) => void runTransferAction(transferNo, "reverse")} onManualProcess={(transferNo) => void runTransferAction(transferNo, "manually_process")} onItemsPageChange={setItemsPage} onItemsPerPageChange={setItemsPerPage} /> ) : null}
); }