"use client"; import { CalendarRange, Eye, ShieldAlert, UserRound } 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 { 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 { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; 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 { AdminLoadingState, 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 { 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"; 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("reconcileTypeFixed"); default: return type; } } function getJobSummaryValue(summary: Record | null | undefined, key: string): number { const raw = summary?.[key]; return typeof raw === "number" && Number.isFinite(raw) ? raw : 0; } function renderPeriodRange( row: Pick, formatTs: (value: string | null | undefined) => string, ): string { const from = row.period_start ? formatTs(row.period_start) : "—"; const to = row.period_end ? formatTs(row.period_end) : "—"; return `${from} ~ ${to}`; } 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 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 [selectedId, setSelectedId] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [items, setItems] = useState(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([]); const [playerLoading, setPlayerLoading] = useState(false); const [selectedPlayer, setSelectedPlayer] = useState(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 : tRef.current("loadFailed")); setJobs(null); } finally { setJobsLoading(false); } }, [page, perPage]); useAsyncEffect(() => { void loadJobs(); }, [page, perPage]); 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 : tRef.current("loadItemsFailed")); setItems(null); } finally { setItemsLoading(false); } }, [selectedId, itemsPage, itemsPerPage]); useAsyncEffect(() => { void loadItems(); }, [selectedId, 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(() => { 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 { 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; const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count"); const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count"); const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount); const hasSelectedRange = dateFrom.trim() !== "" && dateTo.trim() !== ""; return (
{canCreate ? ( {t("createTitle")} {t("createDesc")}
{t("scopeTitle")}

{t("scopeDescription")}

{t("reconcileTypeHint")}

{ setDateFrom(from); setDateTo(to); }} />

{t("dateRangeHint")}

{t("playerScopeTitle")}

{t("playerSearchHint")}

setPlayerSearch(e.target.value)} placeholder={t("playerSearchPlaceholder")} />
{selectedPlayer ? (
{selectedPlayer.site_player_id} {selectedPlayer.nickname ? ` · ${selectedPlayer.nickname}` : ""} {selectedPlayer.username ? ` · ${selectedPlayer.username}` : ""}
{t("playerSelected")} · {selectedPlayer.site_code}
) : null} {playerSearch.trim() !== "" || playerResults.length > 0 || playerLoading ? (
{playerLoading ? ( ) : playerResults.length === 0 ? ( ) : (
{playerResults.map((player) => { const active = selectedPlayer?.id === player.id; return ( ); })}
)}
) : (
{t("playerAllPlayersHint")}
)}
{hasSelectedRange ? selectedPlayer ? t("createSummaryPlayer", { player: selectedPlayer.site_player_id, from: dateFrom, to: dateTo, }) : t("createSummaryAll", { from: dateFrom, to: dateTo, }) : t("createSummaryPending", { defaultValue: "请选择完整的对账日期范围后,再创建任务。", })}
) : (

{t("noCreatePermission")}

)}
{t("jobsTitle")} {t("jobsDesc")}
{jobsErr ?

{jobsErr}

: null} {jobs ? ( <>
{t("table.id", { ns: "common" })} {t("jobNo")} {t("type")} {t("status")} {t("itemCount")} {t("mismatchCount")} {t("period")} {t("finishedAt")} {t("createdAt")} {t("operate")} {jobsLoading && !jobs ? ( ) : jobs.items.length === 0 ? ( ) : ( jobs.items.map((row) => ( {row.id} {row.job_no} {reconcileTypeLabel(row.reconcile_type, t)} {jobStatusLabel(row.status, t)} {getJobSummaryValue(row.summary_json, "item_count")} 0 ? "font-medium text-amber-700" : "text-muted-foreground", )} > {getJobSummaryValue(row.summary_json, "mismatch_count")} {renderPeriodRange(row, formatTs)} {formatTs(row.finished_at)} {formatTs(row.created_at)} { setSelectedId(row.id); setItemsPage(1); setDetailOpen(true); }, }, ]} /> )) )}
{jm ? ( { setPerPage(n); setPage(1); }} onPageChange={setPage} /> ) : null} ) : null}
{ setDetailOpen(open); if (!open) { setSelectedId(null); setItems(null); } }} > {t("detailsTitle")} {selectedJob ? `${selectedJob.job_no} · #${selectedJob.id}` : selectedId != null ? `#${selectedId}` : ""}
{itemsLoading && !items ? ( ) : null} {items ? ( <>
{t("itemCount")}
{selectedJobItemCount}
{t("mismatchCount")}
{selectedJobMismatchCount}
{t("matchedCount")}
{selectedJobMatchedCount}
{t("jobNo")} {items.job_no} · {t("status")} {selectedJob ? ( {jobStatusLabel(selectedJob.status, t)} ) : ( "—" )} · {t("period")} {selectedJob ? renderPeriodRange(selectedJob, formatTs) : "—"}
{t("table.id", { ns: "common" })} {t("sideARef")} {t("sideBRef")} {t("differenceAmount")} {t("status")} {t("detectedAt")} {items.items.length === 0 ? ( ) : ( items.items.map((r) => ( {r.id} {r.side_a_ref ?? "—"} {r.side_b_ref ?? "—"} {r.difference_amount} {itemStatusLabel(r.status, t)} {formatTs(r.created_at)} )) )}
{im ? (
{ setItemsPerPage(n); setItemsPage(1); }} onPageChange={setItemsPage} />
) : null} ) : null}
); }