Files
lotteryAdmin/src/modules/reconcile/reconcile-job-detail-panel.tsx
kang 0d984b00f0
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
feat(admin): 仪表盘重构、代理线路修复与后台体验优化
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics
- 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复
- 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset)
- 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
2026-06-26 14:39:22 +08:00

204 lines
7.7 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { ReconcileItemRow } from "@/modules/reconcile/reconcile-item-row";
import {
getJobSummaryValue,
isOpenReconcileItem,
jobStatusLabel,
renderPeriodRange,
} from "@/modules/reconcile/reconcile-labels";
import type {
AdminReconcileItemsData,
AdminReconcileJobRow,
} from "@/types/api/admin-reconcile";
export type ReconcileItemsFilter = "open" | "all";
type ReconcileJobDetailPanelProps = {
job: AdminReconcileJobRow;
items: AdminReconcileItemsData | null;
itemsLoading: boolean;
itemsFilter: ReconcileItemsFilter;
onItemsFilterChange: (filter: ReconcileItemsFilter) => void;
canWriteWallet: boolean;
actionBusy: boolean;
onCompleteCredit: (transferNo: string) => void;
onReverse: (transferNo: string) => void;
onManualProcess: (transferNo: string) => void;
onItemsPageChange: (page: number) => void;
onItemsPerPageChange: (perPage: number) => void;
};
export function ReconcileJobDetailPanel({
job,
items,
itemsLoading,
itemsFilter,
onItemsFilterChange,
canWriteWallet,
actionBusy,
onCompleteCredit,
onReverse,
onManualProcess,
onItemsPageChange,
onItemsPerPageChange,
}: ReconcileJobDetailPanelProps): React.ReactElement {
const { t } = useTranslation("reconcile");
const formatTs = useAdminDateTimeFormatter();
const mismatchCount = getJobSummaryValue(job.summary_json, "mismatch_count");
const itemCount = getJobSummaryValue(job.summary_json, "item_count");
const displayedItems = useMemo(() => {
if (!items) {
return [];
}
if (itemsFilter === "all") {
return items.items;
}
return items.items.filter(isOpenReconcileItem);
}, [items, itemsFilter]);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex shrink-0 flex-col gap-3 border-b border-border/60 px-5 py-4 pr-12 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="space-y-1">
<h3 className="text-base font-semibold leading-none">{t("detailsTitle")}</h3>
<p className="font-mono text-xs text-muted-foreground break-all">{job.job_no}</p>
</div>
<dl className="grid grid-cols-[auto_auto] gap-x-4 gap-y-1 text-xs text-muted-foreground sm:grid-cols-[repeat(4,auto)]">
<div className="flex items-center gap-1.5">
<dt className="shrink-0">{t("status")}</dt>
<dd>
<AdminStatusBadge status={job.status}>{jobStatusLabel(job.status, t)}</AdminStatusBadge>
</dd>
</div>
<div className="flex items-center gap-1.5">
<dt className="shrink-0">{t("mismatchCount")}</dt>
<dd className="font-medium tabular-nums text-foreground">{mismatchCount}</dd>
</div>
<div className="flex items-center gap-1.5">
<dt className="shrink-0">{t("itemCount")}</dt>
<dd className="font-medium tabular-nums text-foreground">{itemCount}</dd>
</div>
<div className="col-span-2 flex min-w-0 items-start gap-1.5 sm:col-span-1">
<dt className="shrink-0 pt-0.5">{t("period")}</dt>
<dd className="min-w-0 font-mono leading-snug">{renderPeriodRange(job, formatTs)}</dd>
</div>
</dl>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant={itemsFilter === "open" ? "default" : "outline"}
className="h-8"
onClick={() => onItemsFilterChange("open")}
>
{t("filterOpen")}
</Button>
<Button
type="button"
size="sm"
variant={itemsFilter === "all" ? "default" : "outline"}
className="h-8"
onClick={() => onItemsFilterChange("all")}
>
{t("filterAll")}
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{itemsLoading && !items ? (
<AdminLoadingState minHeight="8rem" className="py-8" />
) : (
<>
{items ? (
<p className="mb-3 text-xs text-muted-foreground">
{t("filterCount", {
shown: displayedItems.length,
total: items.meta.total,
})}
</p>
) : null}
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[11rem]">{t("transferNo")}</TableHead>
<TableHead className="min-w-[10rem]">{t("walletTxnNo")}</TableHead>
<TableHead className="min-w-[7rem]">{t("mainSiteCheck")}</TableHead>
<TableHead className="min-w-[9rem]">{t("detectedAt")}</TableHead>
<TableHead className="min-w-[7.5rem]">{t("itemScanFindingColumn")}</TableHead>
<TableHead className="min-w-[5.5rem]">{t("itemCurrentStatusColumn")}</TableHead>
<TableHead className="min-w-[5.5rem] text-right">{t("differenceAmount")}</TableHead>
<TableHead className="w-[3.5rem] text-center">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemsLoading && items ? (
<AdminTableLoadingRow colSpan={8} />
) : displayedItems.length === 0 ? (
<AdminTableNoResourceRow
colSpan={8}
message={
itemsFilter === "open" ? t("filterOpenEmpty") : t("detailsEmpty")
}
/>
) : (
displayedItems.map((item) => (
<ReconcileItemRow
key={item.id}
row={item}
canWriteWallet={canWriteWallet}
actionBusy={actionBusy}
onCompleteCredit={onCompleteCredit}
onReverse={onReverse}
onManualProcess={onManualProcess}
/>
))
)}
</TableBody>
</Table>
</div>
{items?.meta ? (
<div className="mt-3 border-t border-border/60 pt-2">
<AdminListPaginationFooter
selectId={`reconcile-items-per-page-${job.id}`}
total={items.meta.total}
page={items.meta.current_page}
lastPage={Math.max(1, items.meta.last_page)}
perPage={items.meta.per_page}
loading={itemsLoading}
onPerPageChange={(n) => {
onItemsPerPageChange(n);
onItemsPageChange(1);
}}
onPageChange={onItemsPageChange}
/>
</div>
) : null}
</>
)}
</div>
</div>
);
}