1618 行
65 KiB
TypeScript
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>
|
|
);
|
|
}
|