Files
lotteryAdmin/src/modules/reconcile/reconcile-console.tsx

351 lines
13 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 { 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_startISO</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_endISO</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>
);
}