feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
204
src/modules/reconcile/reconcile-job-detail-panel.tsx
Normal file
204
src/modules/reconcile/reconcile-job-detail-panel.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user