351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { toast } from "sonner";
|
||
|
||
import {
|
||
getAdminReconcileJobItems,
|
||
getAdminReconcileJobs,
|
||
postAdminReconcileJob,
|
||
} from "@/api/admin-reconcile";
|
||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||
import { useAdminProfile } from "@/stores/admin-session";
|
||
import { LotteryApiBizError } from "@/types/api/errors";
|
||
import type {
|
||
AdminReconcileItemsData,
|
||
AdminReconcileJobListData,
|
||
} from "@/types/api/admin-reconcile";
|
||
|
||
const MANAGE = ["prd.wallet_reconcile.manage"] as const;
|
||
|
||
export function ReconcileConsole(): React.ReactElement {
|
||
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(25);
|
||
|
||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
const [items, setItems] = useState<AdminReconcileItemsData | null>(null);
|
||
const [itemsPage, setItemsPage] = useState(1);
|
||
const [itemsPerPage, setItemsPerPage] = useState(50);
|
||
const [itemsLoading, setItemsLoading] = useState(false);
|
||
|
||
const [reconcileType, setReconcileType] = useState("wallet_transfer");
|
||
const [periodStart, setPeriodStart] = useState("");
|
||
const [periodEnd, setPeriodEnd] = useState("");
|
||
const [itemsJson, setItemsJson] = useState("[]");
|
||
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 : "加载失败");
|
||
setJobs(null);
|
||
} finally {
|
||
setJobsLoading(false);
|
||
}
|
||
}, [page, perPage]);
|
||
|
||
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 : "加载明细失败");
|
||
setItems(null);
|
||
} finally {
|
||
setItemsLoading(false);
|
||
}
|
||
}, [selectedId, itemsPage, itemsPerPage]);
|
||
|
||
useEffect(() => {
|
||
queueMicrotask(() => {
|
||
void loadItems();
|
||
});
|
||
}, [loadItems]);
|
||
|
||
async function onCreate(): Promise<void> {
|
||
let itemsPayload: Parameters<typeof postAdminReconcileJob>[0]["items"];
|
||
const trimmed = itemsJson.trim();
|
||
if (trimmed !== "" && trimmed !== "[]") {
|
||
try {
|
||
itemsPayload = JSON.parse(trimmed) as NonNullable<
|
||
Parameters<typeof postAdminReconcileJob>[0]["items"]
|
||
>;
|
||
} catch {
|
||
toast.error("items JSON 无法解析");
|
||
return;
|
||
}
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
await postAdminReconcileJob({
|
||
reconcile_type: reconcileType,
|
||
period_start: periodStart.trim() || undefined,
|
||
period_end: periodEnd.trim() || undefined,
|
||
items: itemsPayload,
|
||
});
|
||
toast.success("已创建对账任务");
|
||
setPage(1);
|
||
await loadJobs();
|
||
} catch (e) {
|
||
toast.error(e instanceof LotteryApiBizError ? e.message : "创建失败");
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
const jm = jobs?.meta;
|
||
const im = items?.meta;
|
||
|
||
return (
|
||
<div className="flex w-full max-w-none flex-col gap-8">
|
||
{canCreate ? (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>新建对账任务</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="grid max-w-3xl gap-4">
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="rc-type">reconcile_type</Label>
|
||
<Input
|
||
id="rc-type"
|
||
value={reconcileType}
|
||
onChange={(e) => setReconcileType(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="rc-start">period_start(ISO)</Label>
|
||
<Input
|
||
id="rc-start"
|
||
placeholder="2026-05-01T00:00:00Z"
|
||
value={periodStart}
|
||
onChange={(e) => setPeriodStart(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="rc-end">period_end(ISO)</Label>
|
||
<Input
|
||
id="rc-end"
|
||
placeholder="2026-05-02T00:00:00Z"
|
||
value={periodEnd}
|
||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-1.5">
|
||
<Label htmlFor="rc-items">items JSON(可选数组)</Label>
|
||
<Textarea
|
||
id="rc-items"
|
||
value={itemsJson}
|
||
onChange={(e) => setItemsJson(e.target.value)}
|
||
rows={6}
|
||
className="font-mono text-xs"
|
||
/>
|
||
</div>
|
||
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
|
||
{submitting ? "提交中…" : "创建任务"}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<p className="text-muted-foreground text-sm">当前账号无新建对账任务权限。</p>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<CardTitle>对账任务</CardTitle>
|
||
</div>
|
||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||
刷新
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent className="space-y-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">加载中…</p>
|
||
) : null}
|
||
{jobs ? (
|
||
<>
|
||
<div className="rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-24">ID</TableHead>
|
||
<TableHead>任务号</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
<TableHead>周期</TableHead>
|
||
<TableHead>创建时间</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{jobs.items.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="text-muted-foreground">
|
||
无数据
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
jobs.items.map((row) => (
|
||
<TableRow
|
||
key={row.id}
|
||
className={
|
||
selectedId === row.id
|
||
? "bg-muted/60 cursor-pointer"
|
||
: "cursor-pointer hover:bg-muted/40"
|
||
}
|
||
onClick={() => {
|
||
setSelectedId(row.id);
|
||
setItemsPage(1);
|
||
}}
|
||
>
|
||
<TableCell className="tabular-nums">{row.id}</TableCell>
|
||
<TableCell className="font-mono text-xs">{row.job_no}</TableCell>
|
||
<TableCell>{row.reconcile_type}</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary">{row.status}</Badge>
|
||
</TableCell>
|
||
<TableCell className="max-w-[14rem] truncate text-xs text-muted-foreground">
|
||
{row.period_start ?? "—"} ~ {row.period_end ?? "—"}
|
||
</TableCell>
|
||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||
{formatTs(row.created_at)}
|
||
</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>
|
||
|
||
{selectedId != null ? (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>任务 #{selectedId} 明细</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{itemsLoading && !items ? (
|
||
<p className="text-sm text-muted-foreground">加载中…</p>
|
||
) : null}
|
||
{items ? (
|
||
<>
|
||
{items.job_no ? (
|
||
<p className="font-mono text-sm text-muted-foreground">{items.job_no}</p>
|
||
) : null}
|
||
<div className="rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="w-20">ID</TableHead>
|
||
<TableHead>side_a_ref</TableHead>
|
||
<TableHead>side_b_ref</TableHead>
|
||
<TableHead>差额</TableHead>
|
||
<TableHead>状态</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.items.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={5} className="text-muted-foreground">
|
||
无明细
|
||
</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>{r.status}</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{im ? (
|
||
<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}
|
||
/>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|