Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
204 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
} |