- AP 概览页 Tab 工作台化(待付款/异常付款/风险标记/对账异常) - AP 导出端点支持 CSV/XLSX 与筛选 - 风险标记 resolve/dismiss 支持说明审计留痕 - 支付批次深链、高风险复核提示、对账确认权限收口 - OpenClaw 浏览器级回归全部通过
1344 行
51 KiB
TypeScript
1344 行
51 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
exportApOperations,
|
|
fetchApOperations,
|
|
type ApOperationsPendingEntityType,
|
|
type ApOperationsRiskBucket,
|
|
type ApPartialMatchItemSummary,
|
|
} from "../api/apOperations";
|
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
|
import {
|
|
dismissRiskFlag,
|
|
fetchRiskFlags,
|
|
resolveRiskFlag,
|
|
type RiskFlag,
|
|
} from "../api/feeControl";
|
|
import {
|
|
cancelPaymentBatch,
|
|
executePaymentBatch,
|
|
fetchPaymentBatchDetail,
|
|
fetchPaymentBatches,
|
|
reviewPaymentBatch,
|
|
type PaymentBatch,
|
|
type PaymentBatchEntityType,
|
|
type PaymentBatchItem,
|
|
} from "../api/paymentBatches";
|
|
import { fetchTravelTransactions } from "../api/travelPayments";
|
|
import {
|
|
confirmTravelReconciliationItem,
|
|
fetchTravelReconciliationBatchDetail,
|
|
type TravelReconciliationItem,
|
|
} from "../api/travelReconciliation";
|
|
import { isApiError } from "../api/types";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { hasPermission } from "../auth/permissions";
|
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { Button } from "../components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "../components/ui/dialog";
|
|
import { Input } from "../components/ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
|
import { downloadBlob } from "../lib/download";
|
|
|
|
type OperationsTab = "pending" | "failed" | "risk" | "reconciliation";
|
|
type RiskActionType = "resolve" | "dismiss";
|
|
type PendingEntityFilter = ApOperationsPendingEntityType | "all";
|
|
|
|
type FailedPaymentItemRow = PaymentBatchItem & {
|
|
batchId: string;
|
|
batchNo: string;
|
|
batchName: string;
|
|
entityType: PaymentBatchEntityType;
|
|
batchStatus: PaymentBatch["status"];
|
|
highRisk: boolean;
|
|
reviewRequirement: PaymentBatch["reviewRequirement"];
|
|
};
|
|
|
|
const BUDGET_RISK_CODES = new Set(["budget_exceeded", "budget_threshold_exceeded"]);
|
|
|
|
function resolveErrorMessage(error: unknown, fallback = "操作失败") {
|
|
return isApiError(error)
|
|
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
|
: error instanceof Error
|
|
? error.message
|
|
: fallback;
|
|
}
|
|
|
|
function formatDateTime(value: string | null) {
|
|
if (!value) {
|
|
return "-";
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
return date.toLocaleString("zh-CN", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
function statusBadgeClass(status: string) {
|
|
if (status === "open" || status === "pending" || status === "draft") {
|
|
return "border-amber-200 bg-amber-50 text-amber-700";
|
|
}
|
|
if (status === "resolved" || status === "confirmed" || status === "reviewed") {
|
|
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
|
}
|
|
if (status === "dismissed" || status === "cancelled") {
|
|
return "border-zinc-200 bg-zinc-100 text-zinc-600";
|
|
}
|
|
if (status === "failed") {
|
|
return "border-red-200 bg-red-50 text-red-700";
|
|
}
|
|
return "border-zinc-200 bg-zinc-50 text-zinc-600";
|
|
}
|
|
|
|
function entityTypeLabel(value: string) {
|
|
if (value === "reimbursement") return "报销";
|
|
if (value === "travel_order") return "差旅";
|
|
if (value === "purchase_request") return "采购";
|
|
if (value === "travel_request") return "差旅申请";
|
|
return value || "-";
|
|
}
|
|
|
|
function batchStatusLabel(value: string) {
|
|
if (value === "draft") return "草稿";
|
|
if (value === "reviewed") return "已复核";
|
|
if (value === "executed") return "已执行";
|
|
if (value === "failed") return "执行失败";
|
|
if (value === "cancelled") return "已取消";
|
|
return value || "-";
|
|
}
|
|
|
|
function riskBucketLabel(value: ApOperationsRiskBucket) {
|
|
if (value === "budget_overspend") return "预算超支";
|
|
if (value === "exception") return "异常风险";
|
|
return "全部";
|
|
}
|
|
|
|
function riskLevelLabel(value: string) {
|
|
if (value === "high") return "高";
|
|
if (value === "medium") return "中";
|
|
if (value === "low") return "低";
|
|
return value || "-";
|
|
}
|
|
|
|
function matchStatusLabel(value: string) {
|
|
if (value === "amount_mismatch") return "金额不匹配";
|
|
if (value === "currency_mismatch") return "币种不匹配";
|
|
if (value === "missing_transaction") return "缺少交易";
|
|
if (value === "duplicate_charge") return "重复扣款";
|
|
if (value === "orphan_transaction") return "孤儿交易";
|
|
if (value === "reversed_after_payment") return "付款后冲正";
|
|
if (value === "matched") return "已匹配";
|
|
return value || "-";
|
|
}
|
|
|
|
function resolutionStatusLabel(value: string) {
|
|
if (value === "pending") return "待确认";
|
|
if (value === "confirmed") return "已确认";
|
|
return value || "-";
|
|
}
|
|
|
|
function matchesCreatedAtRange(createdAt: string | null, dateFrom: string, dateTo: string) {
|
|
if (!createdAt) {
|
|
return !dateFrom && !dateTo;
|
|
}
|
|
const dateOnly = createdAt.slice(0, 10);
|
|
if (dateFrom && dateOnly < dateFrom) {
|
|
return false;
|
|
}
|
|
if (dateTo && dateOnly > dateTo) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function riskBucketFromCode(riskCode: string) {
|
|
return BUDGET_RISK_CODES.has(riskCode) ? "budget_overspend" : "exception";
|
|
}
|
|
|
|
function prettyJson(value: unknown) {
|
|
try {
|
|
return JSON.stringify(value ?? {}, null, 2);
|
|
} catch {
|
|
return "{}";
|
|
}
|
|
}
|
|
|
|
function SummaryCard({
|
|
title,
|
|
value,
|
|
tone = "zinc",
|
|
}: {
|
|
title: string;
|
|
value: number | string;
|
|
tone?: "zinc" | "amber" | "red" | "emerald";
|
|
}) {
|
|
const toneClass =
|
|
tone === "amber"
|
|
? "text-amber-700"
|
|
: tone === "red"
|
|
? "text-red-700"
|
|
: tone === "emerald"
|
|
? "text-emerald-700"
|
|
: "text-zinc-900";
|
|
|
|
return (
|
|
<div className="card-std p-5">
|
|
<div className="text-sm text-zinc-500">{title}</div>
|
|
<div className={`mt-2 text-2xl font-bold ${toneClass}`}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionError({ message }: { message: string }) {
|
|
return (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{message}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ApOperationsPage() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { permissions } = useAuth();
|
|
|
|
const canManageBatch = hasPermission(permissions, "payment.batch.manage");
|
|
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
|
|
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
|
|
|
|
const [activeTab, setActiveTab] = useState<OperationsTab>("pending");
|
|
const [pendingEntityType, setPendingEntityType] = useState<PendingEntityFilter>("all");
|
|
const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState("");
|
|
const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState("");
|
|
const [riskBucket, setRiskBucket] = useState<ApOperationsRiskBucket>("all");
|
|
|
|
const [reviewTarget, setReviewTarget] = useState<PaymentBatch | null>(null);
|
|
const [reviewComment, setReviewComment] = useState("");
|
|
const [cancelTarget, setCancelTarget] = useState<PaymentBatch | null>(null);
|
|
const [retryTarget, setRetryTarget] = useState<FailedPaymentItemRow | null>(null);
|
|
|
|
const [failedDetailTarget, setFailedDetailTarget] = useState<FailedPaymentItemRow | null>(null);
|
|
const [riskAction, setRiskAction] = useState<{ type: RiskActionType; item: RiskFlag } | null>(null);
|
|
const [riskActionNote, setRiskActionNote] = useState("");
|
|
|
|
const [reconciliationDetailTarget, setReconciliationDetailTarget] =
|
|
useState<ApPartialMatchItemSummary | null>(null);
|
|
const [reconciliationConfirmTarget, setReconciliationConfirmTarget] =
|
|
useState<ApPartialMatchItemSummary | null>(null);
|
|
const [reconciliationConfirmNote, setReconciliationConfirmNote] = useState("");
|
|
|
|
const [resolvingTravelOrderId, setResolvingTravelOrderId] = useState<string | null>(null);
|
|
const [travelRequestTargets, setTravelRequestTargets] = useState<Record<string, string | null>>({});
|
|
|
|
const summaryQuery = useQuery({
|
|
queryKey: ["ap-operations"],
|
|
queryFn: () => fetchApOperations(),
|
|
});
|
|
|
|
const paymentBatchesQuery = useQuery({
|
|
queryKey: ["payment-batches"],
|
|
queryFn: () => fetchPaymentBatches(),
|
|
});
|
|
|
|
const riskFlagsQuery = useQuery({
|
|
queryKey: ["risk-flags", "open"],
|
|
queryFn: () => fetchRiskFlags({ status: "open" }),
|
|
});
|
|
|
|
const failedDetailQuery = useQuery({
|
|
enabled: Boolean(failedDetailTarget?.batchId),
|
|
queryKey: ["payment-batches", "detail", failedDetailTarget?.batchId],
|
|
queryFn: () => fetchPaymentBatchDetail(failedDetailTarget!.batchId),
|
|
});
|
|
|
|
const reconciliationDetailQuery = useQuery({
|
|
enabled: Boolean(reconciliationDetailTarget?.travelReconciliationBatchId),
|
|
queryKey: [
|
|
"travel-reconciliation",
|
|
"detail",
|
|
reconciliationDetailTarget?.travelReconciliationBatchId,
|
|
],
|
|
queryFn: () =>
|
|
fetchTravelReconciliationBatchDetail(reconciliationDetailTarget!.travelReconciliationBatchId),
|
|
});
|
|
|
|
const paymentBatches = paymentBatchesQuery.data?.items ?? [];
|
|
const openRiskFlags = riskFlagsQuery.data?.items ?? [];
|
|
const partialMatchItems = summaryQuery.data?.partialMatchItems ?? [];
|
|
|
|
const pendingBatches = useMemo(
|
|
() =>
|
|
paymentBatches.filter(
|
|
(item) =>
|
|
["draft", "reviewed", "failed"].includes(item.status) &&
|
|
(pendingEntityType === "all" || item.entityType === pendingEntityType) &&
|
|
matchesCreatedAtRange(item.createdAt, pendingCreatedAtFrom, pendingCreatedAtTo),
|
|
),
|
|
[paymentBatches, pendingCreatedAtFrom, pendingCreatedAtTo, pendingEntityType],
|
|
);
|
|
|
|
const failedItems = useMemo<FailedPaymentItemRow[]>(
|
|
() =>
|
|
paymentBatches
|
|
.flatMap((batch) =>
|
|
batch.items
|
|
.filter((item) => item.status === "failed")
|
|
.map((item) => ({
|
|
...item,
|
|
batchId: batch.id,
|
|
batchNo: batch.batchNo,
|
|
batchName: batch.name,
|
|
entityType: batch.entityType,
|
|
batchStatus: batch.status,
|
|
highRisk: batch.highRisk,
|
|
reviewRequirement: batch.reviewRequirement,
|
|
})),
|
|
)
|
|
.sort((left, right) => {
|
|
const leftTs = new Date(left.createdAt ?? 0).getTime();
|
|
const rightTs = new Date(right.createdAt ?? 0).getTime();
|
|
return rightTs - leftTs;
|
|
}),
|
|
[paymentBatches],
|
|
);
|
|
|
|
const filteredRiskFlags = useMemo(
|
|
() =>
|
|
openRiskFlags.filter((item) => {
|
|
if (riskBucket === "all") {
|
|
return true;
|
|
}
|
|
return riskBucketFromCode(item.riskCode) === riskBucket;
|
|
}),
|
|
[openRiskFlags, riskBucket],
|
|
);
|
|
|
|
const failedDetailItem = useMemo(() => {
|
|
if (!failedDetailTarget) {
|
|
return null;
|
|
}
|
|
return (
|
|
failedDetailQuery.data?.item.items.find((item) => item.id === failedDetailTarget.id) ??
|
|
failedDetailTarget
|
|
);
|
|
}, [failedDetailQuery.data?.item.items, failedDetailTarget]);
|
|
|
|
const reconciliationDetailItem = useMemo<TravelReconciliationItem | null>(() => {
|
|
if (!reconciliationDetailTarget) {
|
|
return null;
|
|
}
|
|
return (
|
|
reconciliationDetailQuery.data?.items.find((item) => item.id === reconciliationDetailTarget.id) ??
|
|
null
|
|
);
|
|
}, [reconciliationDetailQuery.data?.items, reconciliationDetailTarget]);
|
|
|
|
const refreshWorkbench = async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["ap-operations"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["payment-batches"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["risk-flags"] }),
|
|
failedDetailTarget?.batchId
|
|
? queryClient.invalidateQueries({
|
|
queryKey: ["payment-batches", "detail", failedDetailTarget.batchId],
|
|
})
|
|
: Promise.resolve(),
|
|
reconciliationDetailTarget?.travelReconciliationBatchId
|
|
? queryClient.invalidateQueries({
|
|
queryKey: [
|
|
"travel-reconciliation",
|
|
"detail",
|
|
reconciliationDetailTarget.travelReconciliationBatchId,
|
|
],
|
|
})
|
|
: Promise.resolve(),
|
|
]);
|
|
};
|
|
|
|
const reviewMutation = useMutation({
|
|
mutationFn: async (batch: PaymentBatch) => {
|
|
const comment = reviewComment.trim();
|
|
if (!comment) {
|
|
throw new Error("快捷复核必须填写复核意见");
|
|
}
|
|
const resolveRiskFlagIds = Array.from(
|
|
new Set(batch.items.flatMap((item) => item.openRiskFlags.map((flag) => flag.id))),
|
|
);
|
|
return reviewPaymentBatch(batch.id, { comment, resolveRiskFlagIds });
|
|
},
|
|
onSuccess: async ({ item }) => {
|
|
await refreshWorkbench();
|
|
setReviewTarget(null);
|
|
setReviewComment("");
|
|
toast.success(
|
|
item.reviewRequirement.nextStep === "ready_for_execute"
|
|
? "付款批次复核完成"
|
|
: "付款批次已完成首轮复核",
|
|
);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "快捷复核失败"));
|
|
},
|
|
});
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: (batchId: string) => cancelPaymentBatch(batchId),
|
|
onSuccess: async () => {
|
|
await refreshWorkbench();
|
|
setCancelTarget(null);
|
|
toast.success("付款批次已取消");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "取消批次失败"));
|
|
},
|
|
});
|
|
|
|
const executeMutation = useMutation({
|
|
mutationFn: (batchId: string) => executePaymentBatch(batchId),
|
|
onSuccess: async () => {
|
|
await refreshWorkbench();
|
|
setRetryTarget(null);
|
|
toast.success("付款批次已重新执行");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "重试执行失败"));
|
|
},
|
|
});
|
|
|
|
const riskMutation = useMutation({
|
|
mutationFn: async (payload: { type: RiskActionType; item: RiskFlag }) => {
|
|
const note = riskActionNote.trim();
|
|
if (!note) {
|
|
throw new Error(payload.type === "resolve" ? "解决说明不能为空" : "驳回理由不能为空");
|
|
}
|
|
if (payload.type === "resolve") {
|
|
return resolveRiskFlag(payload.item.id, { note });
|
|
}
|
|
return dismissRiskFlag(payload.item.id, { note });
|
|
},
|
|
onSuccess: async (_, payload) => {
|
|
await refreshWorkbench();
|
|
setRiskAction(null);
|
|
setRiskActionNote("");
|
|
toast.success(payload.type === "resolve" ? "风险标记已解决" : "风险标记已驳回");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "处理风险标记失败"));
|
|
},
|
|
});
|
|
|
|
const reconciliationConfirmMutation = useMutation({
|
|
mutationFn: (itemId: string) =>
|
|
confirmTravelReconciliationItem(itemId, {
|
|
note: reconciliationConfirmNote.trim() || null,
|
|
}),
|
|
onSuccess: async () => {
|
|
await refreshWorkbench();
|
|
setReconciliationConfirmTarget(null);
|
|
setReconciliationConfirmNote("");
|
|
toast.success("对账异常已确认");
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "确认对账异常失败"));
|
|
},
|
|
});
|
|
|
|
const exportMutation = useMutation({
|
|
mutationFn: (format: "csv" | "xlsx") =>
|
|
exportApOperations({
|
|
format,
|
|
pendingEntityType: pendingEntityType === "all" ? undefined : pendingEntityType,
|
|
pendingCreatedAtFrom: pendingCreatedAtFrom || undefined,
|
|
pendingCreatedAtTo: pendingCreatedAtTo || undefined,
|
|
riskBucket,
|
|
}),
|
|
onSuccess: (response, format) => {
|
|
downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`);
|
|
toast.success(`AP 运营数据已导出为 ${format.toUpperCase()}`);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error, "导出失败"));
|
|
},
|
|
});
|
|
|
|
const handleJumpToBatch = (batchId: string) => {
|
|
navigate(`/payment-batches?id=${encodeURIComponent(batchId)}`);
|
|
};
|
|
|
|
const handleJumpToLinkedEntity = async (item: FailedPaymentItemRow) => {
|
|
if (item.entityType === "reimbursement") {
|
|
navigate(`/reimbursements/${item.entityId}`);
|
|
return;
|
|
}
|
|
|
|
if (!canManageTravelPayments) {
|
|
toast.error("当前账号无权限解析差旅付款关联申请");
|
|
return;
|
|
}
|
|
|
|
const cachedTarget = travelRequestTargets[item.entityId];
|
|
if (cachedTarget) {
|
|
navigate(`/travel-requests?tab=requests&id=${encodeURIComponent(cachedTarget)}`);
|
|
return;
|
|
}
|
|
if (cachedTarget === null) {
|
|
return;
|
|
}
|
|
|
|
setResolvingTravelOrderId(item.entityId);
|
|
try {
|
|
const response = await fetchTravelTransactions({
|
|
travelOrderId: item.entityId,
|
|
page: 1,
|
|
pageSize: 1,
|
|
});
|
|
const targetId = response.items[0]?.travelRequestId ?? null;
|
|
setTravelRequestTargets((current) => ({ ...current, [item.entityId]: targetId }));
|
|
if (!targetId) {
|
|
toast.error("未找到关联差旅申请,无法跳转");
|
|
return;
|
|
}
|
|
navigate(`/travel-requests?tab=requests&id=${encodeURIComponent(targetId)}`);
|
|
} catch (error) {
|
|
toast.error(resolveErrorMessage(error, "解析关联差旅申请失败"));
|
|
} finally {
|
|
setResolvingTravelOrderId(null);
|
|
}
|
|
};
|
|
|
|
if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading) {
|
|
return <div className="card-std p-6 text-sm text-zinc-500">加载 AP 运营工作台...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="AP 运营"
|
|
subtitle="将待付款批次、异常付款、风险标记和对账异常收口在同一工作台内处理。"
|
|
actions={
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => refreshWorkbench()}
|
|
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching}
|
|
>
|
|
刷新
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => exportMutation.mutate("csv")}
|
|
disabled={exportMutation.isPending}
|
|
>
|
|
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => exportMutation.mutate("xlsx")}
|
|
disabled={exportMutation.isPending}
|
|
>
|
|
{exportMutation.isPending ? "导出中..." : "导出 Excel"}
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{summaryQuery.isError ? (
|
|
<SectionError message={resolveErrorMessage(summaryQuery.error, "AP 摘要加载失败")} />
|
|
) : null}
|
|
{paymentBatchesQuery.isError ? (
|
|
<SectionError message={resolveErrorMessage(paymentBatchesQuery.error, "支付批次数据加载失败")} />
|
|
) : null}
|
|
{riskFlagsQuery.isError ? (
|
|
<SectionError message={resolveErrorMessage(riskFlagsQuery.error, "风险标记数据加载失败")} />
|
|
) : null}
|
|
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
|
|
<SummaryCard
|
|
title="部分匹配"
|
|
value={summaryQuery.data?.summary.partialMatchCount ?? "-"}
|
|
tone="amber"
|
|
/>
|
|
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
|
|
<SummaryCard
|
|
title="预算超支"
|
|
value={summaryQuery.data?.summary.budgetOverspendCount ?? "-"}
|
|
tone="emerald"
|
|
/>
|
|
</div>
|
|
|
|
<div className="card-std p-6">
|
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
|
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
|
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
|
|
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
|
|
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
|
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="pending" className="mt-6 space-y-4">
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium text-zinc-700">实体类型</div>
|
|
<select
|
|
className="select-std"
|
|
value={pendingEntityType}
|
|
onChange={(event) => setPendingEntityType(event.target.value as PendingEntityFilter)}
|
|
>
|
|
<option value="all">全部</option>
|
|
<option value="reimbursement">报销</option>
|
|
<option value="travel_order">差旅</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium text-zinc-700">创建时间从</div>
|
|
<Input
|
|
type="date"
|
|
value={pendingCreatedAtFrom}
|
|
onChange={(event) => setPendingCreatedAtFrom(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium text-zinc-700">创建时间至</div>
|
|
<Input
|
|
type="date"
|
|
value={pendingCreatedAtTo}
|
|
onChange={(event) => setPendingCreatedAtTo(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setPendingEntityType("all");
|
|
setPendingCreatedAtFrom("");
|
|
setPendingCreatedAtTo("");
|
|
}}
|
|
>
|
|
清空筛选
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-zinc-200">
|
|
<Table>
|
|
<TableHeader>
|
|
<tr>
|
|
<TableHead>批次</TableHead>
|
|
<TableHead>实体</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>金额</TableHead>
|
|
<TableHead>创建时间</TableHead>
|
|
<TableHead className="w-[280px]">操作</TableHead>
|
|
</tr>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{pendingBatches.length ? (
|
|
pendingBatches.map((item) => {
|
|
const canReviewSelectedBatch =
|
|
canReviewRisk &&
|
|
((item.status === "draft" &&
|
|
item.reviewRequirement.nextStep === "first_review") ||
|
|
(item.status === "reviewed" &&
|
|
item.reviewRequirement.nextStep === "second_review"));
|
|
const canCancelSelectedBatch =
|
|
canManageBatch && !["executed", "cancelled"].includes(item.status);
|
|
|
|
return (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.batchNo}</div>
|
|
<div className="text-xs text-zinc-500">{item.name}</div>
|
|
</TableCell>
|
|
<TableCell>{entityTypeLabel(item.entityType)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}
|
|
>
|
|
{batchStatusLabel(item.status)}
|
|
</span>
|
|
{item.highRisk ? (
|
|
<span className="inline-flex rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-medium text-red-700">
|
|
高风险
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
复核 {item.reviewRequirement.completedReviewCount}/
|
|
{item.reviewRequirement.requiredReviewCount}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{item.totalAmount}</TableCell>
|
|
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => handleJumpToBatch(item.id)}
|
|
>
|
|
查看批次
|
|
</Button>
|
|
{canReviewRisk ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={!canReviewSelectedBatch}
|
|
onClick={() => {
|
|
setReviewTarget(item);
|
|
setReviewComment("");
|
|
}}
|
|
>
|
|
快捷复核
|
|
</Button>
|
|
) : null}
|
|
{canManageBatch ? (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={!canCancelSelectedBatch}
|
|
onClick={() => setCancelTarget(item)}
|
|
>
|
|
快捷取消
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-8 text-center text-sm text-zinc-500">
|
|
当前筛选下暂无待付款批次。
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="failed" className="mt-6 space-y-4">
|
|
<div className="rounded-lg border border-zinc-200">
|
|
<Table>
|
|
<TableHeader>
|
|
<tr>
|
|
<TableHead>异常项</TableHead>
|
|
<TableHead>批次</TableHead>
|
|
<TableHead>失败原因</TableHead>
|
|
<TableHead>金额</TableHead>
|
|
<TableHead>创建时间</TableHead>
|
|
<TableHead className="w-[320px]">操作</TableHead>
|
|
</tr>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{failedItems.length ? (
|
|
failedItems.map((item) => {
|
|
const canRetry =
|
|
canManageBatch &&
|
|
["reviewed", "failed"].includes(item.batchStatus) &&
|
|
item.reviewRequirement.nextStep === "ready_for_execute";
|
|
const cachedTravelRequestId =
|
|
item.entityType === "travel_order"
|
|
? travelRequestTargets[item.entityId]
|
|
: undefined;
|
|
const jumpDisabled =
|
|
item.entityType === "travel_order"
|
|
? !canManageTravelPayments ||
|
|
cachedTravelRequestId === null ||
|
|
resolvingTravelOrderId === item.entityId
|
|
: false;
|
|
|
|
return (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.entityCode}</div>
|
|
<div className="text-xs text-zinc-500">{item.entityTitle}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.batchNo}</div>
|
|
<div className="text-xs text-zinc-500">{item.batchName}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="text-sm text-zinc-700">
|
|
{item.failureMessage ?? item.failureCode ?? "执行失败"}
|
|
</div>
|
|
<div className="text-xs text-zinc-500">
|
|
{item.failureCode ?? "无失败码"}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.amount} {item.currencyCode}
|
|
</TableCell>
|
|
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-2">
|
|
{canManageBatch ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={!canRetry}
|
|
onClick={() => setRetryTarget(item)}
|
|
>
|
|
重试执行
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setFailedDetailTarget(item)}
|
|
>
|
|
查看详情
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={jumpDisabled}
|
|
onClick={() => handleJumpToLinkedEntity(item)}
|
|
>
|
|
{item.entityType === "travel_order" &&
|
|
travelRequestTargets[item.entityId] === null
|
|
? "无法定位申请单"
|
|
: resolvingTravelOrderId === item.entityId
|
|
? "解析中..."
|
|
: "跳转关联单据"}
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-8 text-center text-sm text-zinc-500">
|
|
当前没有失败付款项。
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="risk" className="mt-6 space-y-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{(
|
|
[
|
|
{ value: "all", label: "全部" },
|
|
{ value: "exception", label: "异常风险" },
|
|
{ value: "budget_overspend", label: "预算超支" },
|
|
] as Array<{ value: ApOperationsRiskBucket; label: string }>
|
|
).map((item) => (
|
|
<Button
|
|
key={item.value}
|
|
type="button"
|
|
size="sm"
|
|
variant={riskBucket === item.value ? "default" : "secondary"}
|
|
onClick={() => setRiskBucket(item.value)}
|
|
>
|
|
{item.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-zinc-200">
|
|
<Table>
|
|
<TableHeader>
|
|
<tr>
|
|
<TableHead>风险项</TableHead>
|
|
<TableHead>分组</TableHead>
|
|
<TableHead>等级</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>创建时间</TableHead>
|
|
<TableHead className="w-[220px]">操作</TableHead>
|
|
</tr>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredRiskFlags.length ? (
|
|
filteredRiskFlags.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
|
<div className="text-xs text-zinc-500">
|
|
{item.detail || `${item.entityType}:${item.entityId}`}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{riskBucketLabel(riskBucketFromCode(item.riskCode))}</TableCell>
|
|
<TableCell>{riskLevelLabel(item.level)}</TableCell>
|
|
<TableCell>
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}
|
|
>
|
|
{item.status}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-2">
|
|
{canReviewRisk ? (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => {
|
|
setRiskAction({ type: "resolve", item });
|
|
setRiskActionNote("");
|
|
}}
|
|
>
|
|
解决
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setRiskAction({ type: "dismiss", item });
|
|
setRiskActionNote("");
|
|
}}
|
|
>
|
|
驳回
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<span className="text-xs text-zinc-500">当前账号仅可查看</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-8 text-center text-sm text-zinc-500">
|
|
当前筛选下暂无开放风险标记。
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="reconciliation" className="mt-6 space-y-4">
|
|
<div className="rounded-lg border border-zinc-200">
|
|
<Table>
|
|
<TableHeader>
|
|
<tr>
|
|
<TableHead>异常</TableHead>
|
|
<TableHead>金额差异</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>创建时间</TableHead>
|
|
<TableHead className="w-[240px]">操作</TableHead>
|
|
</tr>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{partialMatchItems.length ? (
|
|
partialMatchItems.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>
|
|
<div className="font-medium text-zinc-900">
|
|
{item.anomalyCode ?? matchStatusLabel(item.matchStatus)}
|
|
</div>
|
|
<div className="text-xs text-zinc-500">{matchStatusLabel(item.matchStatus)}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div>订单 {item.orderAmount ?? "-"}</div>
|
|
<div className="text-xs text-zinc-500">
|
|
交易 {item.transactionAmount ?? "-"} {item.currency}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<span
|
|
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.resolutionStatus)}`}
|
|
>
|
|
{resolutionStatusLabel(item.resolutionStatus)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setReconciliationDetailTarget(item)}
|
|
>
|
|
查看详情
|
|
</Button>
|
|
{canManageTravelPayments ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={item.resolutionStatus !== "pending"}
|
|
onClick={() => {
|
|
setReconciliationConfirmTarget(item);
|
|
setReconciliationConfirmNote("");
|
|
}}
|
|
>
|
|
确认
|
|
</Button>
|
|
) : (
|
|
<span className="text-xs text-zinc-500">缺少 travel.payment.manage</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="py-8 text-center text-sm text-zinc-500">
|
|
当前没有待确认的对账异常。
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<Dialog
|
|
open={Boolean(reviewTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setReviewTarget(null);
|
|
setReviewComment("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>快捷复核</DialogTitle>
|
|
<DialogDescription>
|
|
{reviewTarget
|
|
? `${reviewTarget.batchNo} · ${reviewTarget.name}`
|
|
: "填写复核意见后提交。"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{reviewTarget?.highRisk ? (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
|
此批次为高风险,需二次复核。
|
|
</div>
|
|
) : null}
|
|
<textarea
|
|
className="min-h-32 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
|
|
placeholder="请输入复核意见"
|
|
value={reviewComment}
|
|
onChange={(event) => setReviewComment(event.target.value)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setReviewTarget(null);
|
|
setReviewComment("");
|
|
}}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={reviewMutation.isPending || !reviewTarget}
|
|
onClick={() => reviewTarget && reviewMutation.mutate(reviewTarget)}
|
|
>
|
|
{reviewMutation.isPending ? "提交中..." : "提交复核"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(failedDetailTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setFailedDetailTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>失败付款项详情</DialogTitle>
|
|
<DialogDescription>
|
|
{failedDetailTarget
|
|
? `${failedDetailTarget.batchNo} · ${failedDetailTarget.entityCode}`
|
|
: "查看失败快照"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{failedDetailQuery.isLoading ? (
|
|
<div className="text-sm text-zinc-500">加载失败项详情...</div>
|
|
) : failedDetailQuery.isError ? (
|
|
<SectionError message={resolveErrorMessage(failedDetailQuery.error, "加载失败项详情失败")} />
|
|
) : failedDetailItem ? (
|
|
<div className="space-y-4 text-sm">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">单据编号</div>
|
|
<div className="mt-1 font-medium text-zinc-900">{failedDetailItem.entityCode}</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">金额</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{failedDetailItem.amount} {failedDetailItem.currencyCode}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">失败码</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{failedDetailItem.failureCode ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">失败原因</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{failedDetailItem.failureMessage ?? "-"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div>
|
|
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100">
|
|
{prettyJson(failedDetailItem.snapshotJson)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(riskAction)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setRiskAction(null);
|
|
setRiskActionNote("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{riskAction?.type === "resolve" ? "解决风险标记" : "驳回风险标记"}</DialogTitle>
|
|
<DialogDescription>
|
|
{riskAction ? `${riskAction.item.title} · ${riskAction.item.riskCode}` : "填写说明后提交。"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<textarea
|
|
className="min-h-32 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
|
|
placeholder={riskAction?.type === "resolve" ? "请输入解决说明" : "请输入驳回理由"}
|
|
value={riskActionNote}
|
|
onChange={(event) => setRiskActionNote(event.target.value)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setRiskAction(null);
|
|
setRiskActionNote("");
|
|
}}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={riskMutation.isPending || !riskAction}
|
|
onClick={() => riskAction && riskMutation.mutate(riskAction)}
|
|
>
|
|
{riskMutation.isPending ? "提交中..." : "确认提交"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(reconciliationDetailTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setReconciliationDetailTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>对账异常详情</DialogTitle>
|
|
<DialogDescription>
|
|
{reconciliationDetailTarget
|
|
? `${reconciliationDetailTarget.anomalyCode ?? reconciliationDetailTarget.matchStatus}`
|
|
: "查看订单金额、交易金额与快照"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{reconciliationDetailQuery.isLoading ? (
|
|
<div className="text-sm text-zinc-500">加载对账异常详情...</div>
|
|
) : reconciliationDetailQuery.isError ? (
|
|
<SectionError message={resolveErrorMessage(reconciliationDetailQuery.error, "加载对账异常详情失败")} />
|
|
) : reconciliationDetailItem ? (
|
|
<div className="space-y-4 text-sm">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">订单金额</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{reconciliationDetailItem.orderAmount ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">交易金额</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{reconciliationDetailItem.transactionAmount ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">异常编码</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{reconciliationDetailItem.anomalyCode ?? "-"}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3">
|
|
<div className="text-xs text-zinc-500">确认状态</div>
|
|
<div className="mt-1 font-medium text-zinc-900">
|
|
{resolutionStatusLabel(reconciliationDetailItem.resolutionStatus)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-2 text-sm font-medium text-zinc-700">快照</div>
|
|
<pre className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-950/95 p-4 text-xs text-zinc-100">
|
|
{prettyJson(reconciliationDetailItem.snapshot)}
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
open={Boolean(reconciliationConfirmTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setReconciliationConfirmTarget(null);
|
|
setReconciliationConfirmNote("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>确认对账异常</DialogTitle>
|
|
<DialogDescription>
|
|
{reconciliationConfirmTarget
|
|
? `${reconciliationConfirmTarget.anomalyCode ?? reconciliationConfirmTarget.matchStatus}`
|
|
: "可选填写备注"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<textarea
|
|
className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm"
|
|
placeholder="备注可空"
|
|
value={reconciliationConfirmNote}
|
|
onChange={(event) => setReconciliationConfirmNote(event.target.value)}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setReconciliationConfirmTarget(null);
|
|
setReconciliationConfirmNote("");
|
|
}}
|
|
>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={reconciliationConfirmMutation.isPending || !reconciliationConfirmTarget}
|
|
onClick={() =>
|
|
reconciliationConfirmTarget &&
|
|
reconciliationConfirmMutation.mutate(reconciliationConfirmTarget.id)
|
|
}
|
|
>
|
|
{reconciliationConfirmMutation.isPending ? "提交中..." : "确认异常"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(cancelTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setCancelTarget(null);
|
|
}
|
|
}}
|
|
title="确认取消付款批次"
|
|
description={
|
|
cancelTarget
|
|
? `将取消 ${cancelTarget.batchNo},该操作与支付批次页保持一致。`
|
|
: undefined
|
|
}
|
|
variant="destructive"
|
|
confirmLabel="确认取消"
|
|
onConfirm={async () => {
|
|
if (!cancelTarget) {
|
|
return;
|
|
}
|
|
await cancelMutation.mutateAsync(cancelTarget.id);
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(retryTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setRetryTarget(null);
|
|
}
|
|
}}
|
|
title="确认重试付款批次"
|
|
description={
|
|
retryTarget
|
|
? `将重新执行 ${retryTarget.batchNo},失败项 ${retryTarget.entityCode} 将按原批次流程重试。`
|
|
: undefined
|
|
}
|
|
confirmLabel="确认重试"
|
|
onConfirm={async () => {
|
|
if (!retryTarget) {
|
|
return;
|
|
}
|
|
await executeMutation.mutateAsync(retryTarget.batchId);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|