文件
hyb-oa/client/src/pages/ApOperationsPage.tsx
T

1618 行
65 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,
fetchFinanceLedger,
type ApOperationsPendingEntityType,
type ApOperationsRiskBucket,
type ApPartialMatchItemSummary,
type FinanceLedgerInvoiceStatus,
type FinanceLedgerPaymentStatus,
type FinanceLedgerStatus,
} 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 { DateTextInput } from "../components/common/DateTextInput";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
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";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
type OperationsTab = "ledger" | "pending" | "failed" | "risk" | "reconciliation";
type RiskActionType = "resolve" | "dismiss";
type PendingEntityFilter = ApOperationsPendingEntityType | "all";
type FinanceLedgerStatusFilter = FinanceLedgerStatus | "all";
type FinanceLedgerPaymentFilter = FinanceLedgerPaymentStatus | "all";
type FinanceLedgerInvoiceFilter = FinanceLedgerInvoiceStatus | "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) {
return formatDisplayDateTime(value);
}
function formatDate(value: string | null | undefined) {
return formatDisplayDate(value);
}
function displayPersonName(value: string | null | undefined, fallback = "-") {
const name = value?.trim();
if (!name) return fallback;
const smokeNameLabels: Record<string, string> = {
Employee: "测试员工",
Manager: "测试主管",
Admin: "测试管理员",
Finance: "测试财务",
"Finance Manager": "测试财务负责人",
};
return smokeNameLabels[name] ?? name;
}
function statusBadgeClass(status: string) {
if (status === "open" || status === "pending" || status === "draft" || status === "reviewing" || status === "unpaid") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
if (status === "resolved" || status === "confirmed" || status === "reviewed" || status === "approved" || status === "paid") {
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 reimbursementStatusLabel(value: string) {
if (value === "draft") return "草稿";
if (value === "reviewing") return "审批中";
if (value === "approved") return "已通过";
if (value === "paid") return "已支付";
if (value === "rejected") return "已驳回";
if (value === "cancelled") return "已取消";
return value || "-";
}
function paymentStatusLabel(value: string) {
if (value === "paid") return "已付款";
if (value === "unpaid") return "未付款";
return value || "-";
}
function invoiceStatusLabel(value: string) {
if (value === "none") return "无发票";
if (value === "pending_ocr") return "识别中";
if (value === "ocr_failed") return "识别失败";
if (value === "available") return "可添加";
if (value === "bound") return "已绑定";
if (value === "archived") return "已归档";
if (value === "mixed") 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 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 [ledgerStatus, setLedgerStatus] = useState<FinanceLedgerStatusFilter>("all");
const [ledgerPaymentStatus, setLedgerPaymentStatus] = useState<FinanceLedgerPaymentFilter>("all");
const [ledgerInvoiceStatus, setLedgerInvoiceStatus] = useState<FinanceLedgerInvoiceFilter>("all");
const [ledgerDateFrom, setLedgerDateFrom] = useState("");
const [ledgerDateTo, setLedgerDateTo] = useState("");
const [ledgerSearch, setLedgerSearch] = useState("");
const [ledgerPage, setLedgerPage] = useState(1);
const [showLedgerAdvancedFilters, setShowLedgerAdvancedFilters] = useState(false);
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 financeLedgerQuery = useQuery({
queryKey: [
"finance-ledger",
ledgerStatus,
ledgerPaymentStatus,
ledgerInvoiceStatus,
ledgerDateFrom,
ledgerDateTo,
ledgerSearch,
ledgerPage,
],
queryFn: () =>
fetchFinanceLedger({
page: ledgerPage,
pageSize: 20,
status: ledgerStatus === "all" ? undefined : ledgerStatus,
paymentStatus: ledgerPaymentStatus === "all" ? undefined : ledgerPaymentStatus,
invoiceStatus: ledgerInvoiceStatus === "all" ? undefined : ledgerInvoiceStatus,
dateFrom: ledgerDateFrom || undefined,
dateTo: ledgerDateTo || undefined,
q: ledgerSearch.trim() || undefined,
}),
});
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 hasLedgerAdvancedFilters = Boolean(ledgerInvoiceStatus !== "all" || ledgerDateFrom || ledgerDateTo);
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: ["finance-ledger"] }),
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,
ledgerStatus: ledgerStatus === "all" ? undefined : ledgerStatus,
ledgerPaymentStatus: ledgerPaymentStatus === "all" ? undefined : ledgerPaymentStatus,
ledgerInvoiceStatus: ledgerInvoiceStatus === "all" ? undefined : ledgerInvoiceStatus,
ledgerDateFrom: ledgerDateFrom || undefined,
ledgerDateTo: ledgerDateTo || undefined,
ledgerSearch: ledgerSearch.trim() || undefined,
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`);
toast.success(`财务处理数据已导出为 ${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 && financeLedgerQuery.isLoading) {
return <div className="card-std p-4 text-sm text-zinc-500 md:p-5">加载财务工作台...</div>;
}
return (
<ListWorkspace>
<WorkspaceHeader
title="应付账款"
actions={
<>
<Button
type="button"
variant="secondary"
onClick={() => refreshWorkbench()}
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching || financeLedgerQuery.isFetching}
>
刷新
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="ap-export"
type="button"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{summaryQuery.isError ? (
<SectionError message={resolveErrorMessage(summaryQuery.error, "应付摘要加载失败")} />
) : null}
{paymentBatchesQuery.isError ? (
<SectionError message={resolveErrorMessage(paymentBatchesQuery.error, "付款批次数据加载失败")} />
) : null}
{riskFlagsQuery.isError ? (
<SectionError message={resolveErrorMessage(riskFlagsQuery.error, "风险标记数据加载失败")} />
) : null}
{financeLedgerQuery.isError ? (
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
) : null}
<div className="finance-command-panel">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="ops-command-bar__tabs !w-full !justify-start">
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
</TabsList>
<TabsContent value="ledger" className="mt-6 space-y-4">
<div className="finance-command-panel space-y-3 !p-3">
<div className="grid gap-3 xl:grid-cols-[minmax(240px,1fr)_160px_160px_auto_auto]">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">搜索</div>
<Input
placeholder="单号、姓名、部门、项目"
value={ledgerSearch}
onChange={(event) => {
setLedgerSearch(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">审批状态</div>
<select
className="select-std"
value={ledgerStatus}
onChange={(event) => {
setLedgerStatus(event.target.value as FinanceLedgerStatusFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="approved">已通过</option>
<option value="paid">已支付</option>
<option value="reviewing">审批中</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">付款状态</div>
<select
className="select-std"
value={ledgerPaymentStatus}
onChange={(event) => {
setLedgerPaymentStatus(event.target.value as FinanceLedgerPaymentFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="unpaid">未付款</option>
<option value="paid">已付款</option>
</select>
</div>
<div className="flex items-end">
<Button
type="button"
variant={showLedgerAdvancedFilters || hasLedgerAdvancedFilters ? "default" : "secondary"}
onClick={() => setShowLedgerAdvancedFilters((current) => !current)}
>
更多筛选
</Button>
</div>
<div className="flex items-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setLedgerStatus("all");
setLedgerPaymentStatus("all");
setLedgerInvoiceStatus("all");
setLedgerDateFrom("");
setLedgerDateTo("");
setLedgerSearch("");
setLedgerPage(1);
}}
>
清空
</Button>
</div>
</div>
</div>
{showLedgerAdvancedFilters || hasLedgerAdvancedFilters ? (
<div className="finance-command-panel grid gap-3 !p-3 md:grid-cols-3">
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">发票状态</div>
<select
className="select-std"
value={ledgerInvoiceStatus}
onChange={(event) => {
setLedgerInvoiceStatus(event.target.value as FinanceLedgerInvoiceFilter);
setLedgerPage(1);
}}
>
<option value="all">全部</option>
<option value="none">无发票</option>
<option value="pending_ocr">识别中</option>
<option value="ocr_failed">识别失败</option>
<option value="available">可添加</option>
<option value="bound">已绑定</option>
<option value="archived">已归档</option>
</select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期从</div>
<DateTextInput
value={ledgerDateFrom}
onChange={(event) => {
setLedgerDateFrom(event.target.value);
setLedgerPage(1);
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">报销日期至</div>
<DateTextInput
value={ledgerDateTo}
onChange={(event) => {
setLedgerDateTo(event.target.value);
setLedgerPage(1);
}}
/>
</div>
</div>
) : null}
<div className="ops-table-focus overflow-x-auto" data-testid="finance-ledger-table">
<Table>
<TableHeader>
<tr>
<TableHead>报销单</TableHead>
<TableHead>申请人 / 部门</TableHead>
<TableHead>项目</TableHead>
<TableHead>金额</TableHead>
<TableHead>审批状态</TableHead>
<TableHead>付款状态</TableHead>
<TableHead>发票 / 附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[120px]">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{financeLedgerQuery.data?.items.length ? (
financeLedgerQuery.data.items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.formNumber ?? item.id}</div>
<div className="text-xs text-zinc-500">{item.description || "无说明"}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{displayPersonName(item.owner?.name ?? item.submitter?.name)}</div>
<div className="text-xs text-zinc-500">{item.department?.name ?? "-"}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.project?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">{item.project?.projectNo ?? "未关联项目"}</div>
</TableCell>
<TableCell>{item.totalAmount}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.status)}`}>
{reimbursementStatusLabel(item.status)}
</span>
</TableCell>
<TableCell>
<div className="space-y-1">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClass(item.payment.status)}`}>
{paymentStatusLabel(item.payment.status)}
</span>
<div className="text-xs text-zinc-500">
{formatDate(item.payment.paymentDate)} · {displayPersonName(item.payment.paidBy?.name, "未记录操作人")}
</div>
<div className="text-xs text-zinc-500">
凭证:{item.payment.paymentVoucherNo ?? "-"}
</div>
</div>
</TableCell>
<TableCell>
<div>{invoiceStatusLabel(item.invoice.status)} · {item.invoice.counts.total} 张</div>
<div className="text-xs text-zinc-500">附件 {item.attachmentCount} 个</div>
</TableCell>
<TableCell>{formatDateTime(item.updatedAt)}</TableCell>
<TableCell>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => navigate(`/reimbursements/${item.id}`)}
>
查看
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={9} className="py-8 text-center text-sm text-zinc-500">
当前筛选下暂无财务台账记录。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm text-zinc-500">
<div>
共 {financeLedgerQuery.data?.total ?? 0} 条,第 {financeLedgerQuery.data?.page ?? ledgerPage} 页
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
size="sm"
disabled={ledgerPage <= 1 || financeLedgerQuery.isFetching}
onClick={() => setLedgerPage((current) => Math.max(1, current - 1))}
>
上一页
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={
financeLedgerQuery.isFetching ||
(financeLedgerQuery.data
? ledgerPage * financeLedgerQuery.data.pageSize >= financeLedgerQuery.data.total
: true)
}
onClick={() => setLedgerPage((current) => current + 1)}
>
下一页
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="pending" className="mt-6 space-y-4">
<div className="finance-command-panel grid gap-3 !p-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>
<DateTextInput
value={pendingCreatedAtFrom}
onChange={(event) => setPendingCreatedAtFrom(event.target.value)}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-zinc-700">创建时间至</div>
<DateTextInput
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="ops-table-focus overflow-x-auto">
<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="ops-table-focus overflow-x-auto">
<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="ops-table-focus overflow-x-auto">
<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="ops-table-focus overflow-x-auto">
<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);
}}
/>
</ListWorkspace>
);
}