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

1397 行
61 KiB
TypeScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
import { ReimbursementBatchClassification } from "../components/reimbursements/ReimbursementBatchClassification";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { fetchExpenseTypes } from "../api/expenseTypes";
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
import {
createReimbursementItem,
deleteReimbursementAttachment,
deleteReimbursement,
deleteReimbursementItem,
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
precheckReimbursement,
submitReimbursement,
updateReimbursementItem,
uploadReimbursementAttachments,
withdrawReimbursement,
linkReimbursementInvoiceItem,
type ReimbursementItem,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import {
bindInvoice,
fetchBindableInvoices,
fetchInvoiceSignedUrl,
unbindInvoice,
uploadInvoice,
} from "../api/invoicePool";
import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary, isTravelReimbursement } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { useUnsavedChangesGuard } from "../components/common/UnsavedChangesGuard";
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { resolveReimbursementPaymentHandoff } from "../lib/reimbursementPaymentHandoff";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { BusinessDetailActionPanel } from "../components/patterns/BusinessDetailTemplate";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
import {
buildPcDetailSource,
resolvePcDetailSource,
type PcDetailNavigationState,
} from "../lib/pcDetailNavigation";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
type ItemEditorInitialValues,
type ItemEditorSaveData,
} from "./reimbursement-detail/ReimbursementItemEditor";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
import {
focusFirstInvoiceGuardIssue,
parseReimbursementInvoiceGuardIssues,
resolveInvoiceGuardIssuesAfterLink,
type ReimbursementInvoiceGuardIssues,
} from "../lib/reimbursementInvoiceGuard";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>;
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return resolveUserErrorMessage(error, fallbackMessage);
}
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const location = useLocation();
const navigate = useNavigate();
const { permissions, me, accessToken } = useAuth();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState("");
const [invoiceGuardIssues, setInvoiceGuardIssues] = useState<ReimbursementInvoiceGuardIssues | null>(null);
const [invoiceItemLinkErrors, setInvoiceItemLinkErrors] = useState<Record<string, string>>({});
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
const [itemError, setItemError] = useState("");
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
const [showSecondaryDetail, setShowSecondaryDetail] = useState(false);
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false);
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
const deselectedInvoiceIds = useRef(new Set<string>());
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
expenseTypeId: "",
expenseDate: "",
amount: "",
description: "",
});
const currentDetailSource = buildPcDetailSource(location.pathname, location.search);
const navigationState = location.state as PcDetailNavigationState | null;
const backSource = resolvePcDetailSource(navigationState?.from, currentDetailSource) ?? "/reimbursements";
const backLabel = backSource.startsWith("/workflow/tasks/") ? "返回审批详情" : "返回报销列表";
const runGuarded = useUnsavedChangesGuard(
Boolean(itemEditor) ||
selectedInvoiceFiles.length > 0 ||
selectedAttachmentFiles.length > 0 ||
Object.keys(selectedInvoices).length > 0,
"报销表单",
);
const detailQuery = useQuery({
enabled: Boolean(reimbursementId),
queryKey: ["reimbursements", reimbursementId],
queryFn: () => fetchReimbursementDetail(reimbursementId),
staleTime: 0,
refetchOnMount: true,
refetchOnWindowFocus: (query) => Boolean(query.state.data && !["draft", "rejected"].includes(query.state.data.status)),
});
const expenseTypesQuery = useQuery({
queryKey: ["expenseTypes", "enabled"],
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const canSubmitExpense = hasPermission(permissions, "expense.submit");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canManagePaymentBatch = hasPermission(permissions, "payment.batch.manage");
const reimbursementPrecheckQuery = useQuery({
enabled: Boolean(
reimbursementId &&
canSubmitExpense &&
detailQuery.data &&
me?.id &&
[detailQuery.data.ownerId, detailQuery.data.submitterId].includes(me.id) &&
["draft", "rejected"].includes(detailQuery.data.status),
),
queryKey: ["reimbursements", reimbursementId, "precheck"],
queryFn: () => precheckReimbursement(reimbursementId),
});
const bindableInvoicesQuery = useQuery({
enabled:
Boolean(reimbursementId) &&
isInvoiceDialogOpen &&
canUploadInvoice &&
canViewOwnInvoice &&
Boolean(detailQuery.data) &&
["draft", "rejected"].includes(detailQuery.data?.status ?? "") &&
Boolean(me?.id) &&
[detailQuery.data?.ownerId, detailQuery.data?.submitterId].includes(me?.id ?? ""),
queryKey: ["invoice-pool", "bindable", reimbursementId, invoiceSearch],
queryFn: () =>
fetchBindableInvoices({
reimbursementId,
search: invoiceSearch || undefined,
limit: 20,
}),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
useEffect(() => {
const timerId = window.setTimeout(() => {
setInvoiceSearch(invoiceSearchInput.trim());
}, 300);
return () => window.clearTimeout(timerId);
}, [invoiceSearchInput]);
useEffect(() => {
const candidateIds = new Set((bindableInvoicesQuery.data?.items ?? []).map((item) => item.id));
setSelectedInvoices((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => candidateIds.has(invoiceId))),
);
}, [bindableInvoicesQuery.data?.items]);
const mutation = useMutation({
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string }) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
if (params.action === "submit") {
return submitReimbursement(reimbursementId);
}
if (params.action === "withdraw") {
return withdrawReimbursement(reimbursementId, params.comment);
}
},
onSuccess: async (data, params) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]);
setActionError("");
setInvoiceGuardIssues(null);
if (params.action === "submit") {
setSubmitConfirmPrecheck(null);
}
const requiresManualReview = params.action === "submit"
&& data != null
&& typeof data === "object"
&& "invoiceAmountManualReviewRequired" in data
&& data.invoiceAmountManualReviewRequired === true;
const actionMessages: Record<string, string> = {
submit: requiresManualReview ? "已提交,票面金额将由财务核对" : "已提交",
withdraw: "已撤回",
};
toast.success(actionMessages[params.action] ?? "操作成功");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "操作失败,请稍后重试");
const issues = parseReimbursementInvoiceGuardIssues(error);
setSubmitConfirmPrecheck(null);
setActionError(message);
setInvoiceGuardIssues(issues);
if (issues) window.requestAnimationFrame(focusFirstInvoiceGuardIssue);
toast.error(message);
},
});
const remindMutation = useMutation({
mutationFn: (taskId: string) => remindWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
]);
toast.success("已催办当前审批人");
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "催办失败,请稍后重试"));
},
});
const itemSaveMutation = useMutation({
mutationFn: async (data: ItemEditorSaveData) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
if (!itemEditor) {
throw new Error("missing itemEditor");
}
const payload = {
expenseTypeId: data.expenseTypeId || null,
expenseDate: data.expenseDate,
amount: data.amount,
description: data.description.trim(),
};
if (itemEditor.mode === "create") {
return createReimbursementItem(reimbursementId, payload);
}
return updateReimbursementItem(reimbursementId, itemEditor.itemId, payload);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]);
setItemEditor(null);
setItemError("");
setInvoiceGuardIssues(null);
toast.success("明细已保存");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "保存明细失败,请稍后重试");
setItemError(message);
toast.error(message);
},
});
const itemDeleteMutation = useMutation({
mutationFn: async (itemId: string) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return deleteReimbursementItem(reimbursementId, itemId);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
]);
setItemEditor(null);
setConfirmDeleteItemId("");
setItemError("");
toast.success("明细已删除");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "删除明细失败,请稍后重试");
setItemError(message);
toast.error(message);
},
});
const deleteReimbursementMutation = useMutation({
mutationFn: async () => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
return deleteReimbursement(reimbursementId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("报销单已删除");
navigate("/reimbursements?status=draft");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "删除报销单失败");
setActionError(message);
toast.error(message);
},
});
const bindInvoicesMutation = useMutation({
mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => {
if (!reimbursementId) {
throw new Error("missing reimbursementId");
}
const results: Array<{ invoiceId: string; success: boolean; message?: string }> = [];
for (const selection of invoiceSelections) {
try {
await bindInvoice(selection.invoiceId, {
reimbursementId,
...(selection.linkedItemId ? { linkedItemId: selection.linkedItemId } : { createItem: true as const }),
});
results.push({ invoiceId: selection.invoiceId, success: true });
} catch (error) {
results.push({
invoiceId: selection.invoiceId,
success: false,
message: buildActionErrorMessage(error, "添加发票失败"),
});
}
}
return results;
},
onSuccess: async (results) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
]);
const failedResults = results.filter((item) => !item.success);
const succeededIds = results.filter((item) => item.success).map((item) => item.invoiceId);
setSelectedInvoices((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
);
if (succeededIds.length) setInvoiceGuardIssues(null);
if (!failedResults.length) {
setInvoiceActionError("");
setIsInvoiceDialogOpen(false);
setInvoiceUploadLinkedItemId("");
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setPendingOcrInvoiceIds((previous) =>
previous.filter((invoiceId) => !succeededIds.includes(invoiceId)),
);
toast.success(`已添加 ${succeededIds.length} 张发票`);
return;
}
const summary = failedResults
.map((item) => {
const invoice = (bindableInvoicesQuery.data?.items ?? []).find((candidate) => candidate.id === item.invoiceId);
return `${formatInvoiceDisplayTitle(invoice)}:${item.message ?? "添加失败"}`;
})
.join(";");
setInvoiceActionError(summary);
if (succeededIds.length) {
toast.success(`已添加 ${succeededIds.length} 张,${failedResults.length} 张失败`);
} else {
toast.error("发票添加失败");
}
},
onError: (error) => {
const message = buildActionErrorMessage(error, "发票添加失败");
setInvoiceActionError(message);
toast.error(message);
},
});
const handleTrackedInvoicesAvailable = useCallback(
(invoiceIds: string[]) => {
if (!invoiceIds.length) {
return;
}
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setSelectedInvoices((previous) => {
const next = { ...previous };
for (const invoiceId of invoiceIds) {
if (!deselectedInvoiceIds.current.has(invoiceId) && !(invoiceId in next)) {
next[invoiceId] = pendingOcrLinkedItemIds[invoiceId] || "";
}
}
return next;
});
},
[pendingOcrLinkedItemIds, queryClient],
);
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items
.map((item) => `${formatInvoiceDisplayTitle(item)}:${resolveInvoiceUserMessage(item)}`)
.join(";");
setInvoiceActionError(message);
const unavailableIds = new Set(items.map((item) => item.id));
setPendingOcrLinkedItemIds((previous) =>
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
);
toast.error("部分发票需要处理");
}, []);
const invoiceOcrTracker = useInvoiceOcrTracker({
accessToken,
enabled: Boolean(reimbursementId),
pendingInvoiceIds: pendingOcrInvoiceIds,
setPendingInvoiceIds: setPendingOcrInvoiceIds,
onAvailable: handleTrackedInvoicesAvailable,
onUnavailable: handleTrackedInvoicesUnavailable,
});
const unbindInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] }),
]);
setInvoiceActionError("");
setInvoiceGuardIssues(null);
toast.success("发票已移除");
},
onError: (error) => {
const message = buildActionErrorMessage(error, "移除发票失败");
setInvoiceActionError(message);
toast.error(message);
},
});
const linkInvoiceItemMutation = useMutation({
mutationFn: ({ invoiceId, linkedItemId }: { invoiceId: string; linkedItemId: string }) =>
linkReimbursementInvoiceItem(reimbursementId, invoiceId, linkedItemId),
onSuccess: async (_result, variables) => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
setInvoiceGuardIssues((previous) => resolveInvoiceGuardIssuesAfterLink(
previous,
variables.invoiceId,
variables.linkedItemId,
));
setInvoiceItemLinkErrors((previous) => {
const next = { ...previous };
delete next[variables.invoiceId];
return next;
});
toast.success("发票已关联到报销明细");
},
onError: (error, variables) => {
const message = buildActionErrorMessage(error, "关联明细失败,请刷新后重试");
setInvoiceItemLinkErrors((previous) => ({ ...previous, [variables.invoiceId]: message }));
toast.error(message);
},
});
const uploadInvoiceMutation = useMutation({
mutationFn: async (files: File[]) => {
const ownerEmployeeId = detailQuery.data?.ownerId;
if (!ownerEmployeeId) {
throw new Error("missing ownerEmployeeId");
}
if (!files.length) {
throw new Error("请选择发票文件");
}
const uploadFailures: Array<{ file: File; message: string }> = [];
const pendingById = new Map<string, string>();
const availableIds: string[] = [];
const ocrFailures: string[] = [];
const bindFailures: string[] = [];
setInvoiceUploadProgress({ completed: 0, total: files.length });
for (const [index, file] of files.entries()) {
try {
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
if (result.uploadAction === "blocked" || !result.invoicePoolId) {
uploadFailures.push({
file,
message: resolveInvoiceUserMessage(result),
});
} else if (result.status === "available") {
availableIds.push(result.invoicePoolId);
} else if (result.status === "pending_ocr") {
pendingById.set(result.invoicePoolId, file.name);
} else {
uploadFailures.push({
file,
message: resolveInvoiceUserMessage(result),
});
}
} catch (error) {
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
}
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
}
setPendingOcrInvoiceIds((previous) =>
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
);
if (invoiceUploadLinkedItemId) {
setPendingOcrLinkedItemIds((previous) => ({
...previous,
...Object.fromEntries(Array.from(pendingById.keys()).map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
}));
}
setSelectedInvoices((previous) => ({
...Object.fromEntries(availableIds.filter((id) => !deselectedInvoiceIds.current.has(id)).map((id) => [id, invoiceUploadLinkedItemId])),
...previous,
}));
return {
uploadedCount: files.length - uploadFailures.length,
uploadFailures,
bindFailures,
pendingIds: Array.from(pendingById.keys()),
ocrFailures,
};
},
onSuccess: async (result) => {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
await bindableInvoicesQuery.refetch();
const blockingMessages = [
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
...result.ocrFailures,
...result.bindFailures,
];
if (blockingMessages.length) {
const message = blockingMessages.join(";");
setInvoiceActionError(message);
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`);
return;
}
setInvoiceActionError("");
if (result.pendingIds.length) {
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后默认勾选,请点击添加到报销单`);
return;
}
toast.success(`发票上传完成:${result.uploadedCount} 张已入我的发票,请选择后添加`);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
onError: (error) => {
const message = buildActionErrorMessage(error, "上传发票失败");
setInvoiceActionError(message);
toast.error(message);
},
});
const uploadAttachmentsMutation = useMutation({
mutationFn: async (files: File[]) => {
if (!files.length) {
throw new Error("请选择附件材料");
}
if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) {
throw new Error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`);
}
return uploadReimbursementAttachments(reimbursementId, files);
},
onSuccess: async () => {
setSelectedAttachmentFiles([]);
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
toast.success("附件材料已上传");
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "上传附件失败"));
},
});
const deleteAttachmentMutation = useMutation({
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason: string }) =>
deleteReimbursementAttachment(reimbursementId, attachmentId, reason),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
setDeleteAttachmentTarget(null);
toast.success("附件材料已删除");
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "删除附件失败"));
},
});
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
const openInvoiceMutation = useMutation({
mutationFn: async (invoiceId: string) => {
const result = await fetchInvoiceSignedUrl(invoiceId);
const openedInWecom = await previewFileInWecom({
url: result.signedUrl,
name: result.originalName,
size: result.fileSize,
});
if (!openedInWecom) {
openFileInBrowser(result.signedUrl);
}
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "打开发票失败"));
},
});
const openAttachmentMutation = useMutation({
mutationFn: async ({ attachment, reason }: {
attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields;
reason?: string;
}) => {
if (isWecomWebView()) {
const result = await fetchReimbursementAttachmentPreviewUrl(reimbursementId, attachment.id, reason);
await previewFileInWecom({ url: result.signedUrl, name: result.originalName, size: result.fileSize });
return null;
}
return downloadReimbursementAttachment(reimbursementId, attachment.id, reason);
},
onSuccess: (result) => {
if (!result) return;
const url = URL.createObjectURL(result.blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
},
onError: (error) => {
toast.error(buildActionErrorMessage(error, "打开附件失败"));
},
});
async function handleOpenAttachment(attachment: { id: string; originalName: string; fileSize: number } & AttachmentGovernanceFields) {
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
openAttachmentMutation.mutate({ attachment, reason });
}
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
if (isWecomWebView()) {
try {
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
return;
} catch {
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
}
}
openFileInBrowser(bundle.signedUrl);
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
toast.error(message);
},
});
async function handleOpenAllMaterials() {
const governedAttachment = (detailQuery.data?.attachments ?? []).find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
if (!reimbursementId) {
return (
<div className="card-std p-5">
<p className="text-sm text-red-600">缺少 reimbursementId 参数。</p>
</div>
);
}
if (detailQuery.isLoading) {
return <CardSkeleton lines={8} />;
}
if (detailQuery.isError || !detailQuery.data) {
const message = resolveUserErrorMessage(detailQuery.error, "加载报销详情失败");
return (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-600">{message}</p>
<Button asChild variant="secondary">
<Link replace to={backSource}>{backLabel}</Link>
</Button>
</div>
);
}
const detail = detailQuery.data;
const displayCode = resolveReimbursementDisplayCode(detail);
const budgetHint = detail.budgetHint;
const canEditItems = ["draft", "rejected"].includes(detail.status);
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
const canMaintainReimbursement = canEditItems && isReimbursementParticipant;
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
const canRemindPendingWorkflow = Boolean(
detail.status === "reviewing" &&
pendingTaskId &&
isReimbursementParticipant &&
detail.pendingWorkflow?.task?.assignee_id !== me?.id,
);
const canOpenInvoiceBindDialog =
canEditItems &&
isReimbursementParticipant &&
canUploadInvoice &&
canViewOwnInvoice;
const canUnbindInvoices = canMaintainReimbursement;
const canDeleteReimbursement =
["draft", "rejected"].includes(detail.status) &&
!detail.deletedAt &&
canDeleteExpense &&
isReimbursementParticipant;
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
const selectedInvoiceEntries = Object.entries(selectedInvoices);
function handleInvoicePdfSelection(files: FileList | null) {
const { accepted, rejected } = splitPdfFiles(files);
setSelectedInvoiceFiles(accepted);
const message = rejectedPdfMessage(rejected);
setActionError(message);
if (message) {
toast.warning(message);
}
}
const expenseTypes = expenseTypesQuery.data?.items ?? [];
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
const defaultDate =
item?.expenseDate ??
detail.reimbursementDate ??
new Date().toISOString().slice(0, 10);
const defaultExpenseTypeId =
item ? item.expenseTypeId ?? "" : expenseTypes[0]?.id ?? "";
return {
expenseDate: defaultDate,
expenseTypeId: defaultExpenseTypeId,
amount: item?.amount ?? "",
description: item?.description ?? "",
};
};
const startCreateItem = () => {
runGuarded(() => {
setItemEditor({ mode: "create" });
setEditorInitialValues(computeEditorDefaults());
setItemError("");
});
};
const startEditItem = (item: ReimbursementItem) => {
runGuarded(() => {
setItemEditor({ mode: "edit", itemId: item.id });
setEditorInitialValues(computeEditorDefaults(item));
setItemError("");
});
};
const handleItemSave = (data: ItemEditorSaveData) => {
itemSaveMutation.mutate(data);
};
const handleItemCancel = () => {
runGuarded(() => {
setItemEditor(null);
setItemError("");
});
};
const handleSubmitAction = async () => {
if (!reimbursementId) {
return;
}
try {
setActionError("");
setInvoiceGuardIssues(null);
if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期";
setActionError(message);
toast.error(message);
return;
}
if (requiresAnyInvoice && detail.invoices.length < 1) {
setActionError(missingInvoiceSubmitMessage);
toast.error(missingInvoiceSubmitMessage);
return;
}
const precheck = await precheckReimbursement(reimbursementId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setActionError(message);
toast.error("提交前校验未通过");
return;
}
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
setSubmitConfirmPrecheck(precheck);
return;
}
mutation.mutate({ action: "submit" });
} catch (error) {
const message = buildActionErrorMessage(error, "提交前校验失败,请稍后重试");
setActionError(message);
toast.error(message);
}
};
const handleInvoiceDialogOpenChange = (open: boolean) => {
setIsInvoiceDialogOpen(open);
if (!open) {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setInvoiceUploadLinkedItemId("");
setInvoiceActionError("");
setPendingOcrInvoiceIds([]);
}
};
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
if (checked) deselectedInvoiceIds.current.delete(invoiceId);
else deselectedInvoiceIds.current.add(invoiceId);
setSelectedInvoices((previous) => {
if (!checked) {
const next = { ...previous };
delete next[invoiceId];
return next;
}
return {
...previous,
[invoiceId]: previous[invoiceId] ?? invoiceUploadLinkedItemId,
};
});
};
const handleBindSelectedInvoices = () => {
if (!selectedInvoiceEntries.length) {
return;
}
bindInvoicesMutation.mutate(
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
invoiceId,
linkedItemId: linkedItemId || undefined,
})),
);
};
const itemCount = detail.items.length;
const invoiceCount = detail.invoices.length;
const hasBlockingPrecheck = blockStandardChecks.length > 0;
const processingProgress = detail.processingProgress ?? { stages: [] };
const paymentHandoff = resolveReimbursementPaymentHandoff({ ...detail, progress: detail.processingProgress });
const currentProcessingStage = processingProgress.stages.find((stage) =>
stage.state === "current" || stage.state === "exception",
);
const nextStepText = (() => {
if (detail.deletedAt) return "已删除,仅供查看历史记录";
if (["draft", "rejected"].includes(detail.status)) {
if (!isReimbursementParticipant) {
return detail.status === "rejected" ? "等待报销人修改后重提" : "等待报销人完善";
}
if (!itemCount) {
return "补报销明细";
}
if (requiresAnyInvoice && !invoiceCount) {
return "添加发票";
}
if (hasBlockingPrecheck) {
return "修改超标明细";
}
return "提交审批";
}
if (detail.status === "reviewing") {
return currentProcessingStage?.detail ?? "查看处理进度";
}
if (paymentHandoff) return paymentHandoff.nextStep;
return "查看记录";
})();
const primaryAction = (() => {
if (canMaintainReimbursement) {
if (!itemCount) {
return (
<Button
data-testid="reimbursement-primary-action"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
>
新增明细
</Button>
);
}
if (requiresAnyInvoice && !invoiceCount) {
return (
<Button
data-testid="reimbursement-primary-action"
disabled={!canOpenInvoiceBindDialog}
onClick={() => handleInvoiceDialogOpenChange(true)}
type="button"
>
添加发票
</Button>
);
}
return (
<Button
data-testid={TEST_IDS.reimbursement.submit}
disabled={mutation.isPending || hasBlockingPrecheck}
onClick={() => void handleSubmitAction()}
type="button"
>
{mutation.isPending ? "提交中..." : "提交报销"}
</Button>
);
}
if (paymentHandoff?.targetHref) {
return <Button asChild><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button>;
}
if (paymentHandoff?.canCreateBatch && canManagePaymentBatch) {
return (
<Button asChild data-testid="reimbursement-open-pending-payment-batches">
<Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}>
去组建付款批次
</Link>
</Button>
);
}
return null;
})();
const hasMoreActions = detail.status !== "paid" || canDeleteReimbursement;
const secondaryActions = [
detail.status === "reviewing" && canRemindPendingWorkflow && pendingTaskId ? (
<Button
key="remind"
data-testid="reimbursement-remind-workflow"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
type="button"
variant="secondary"
>
{remindMutation.isPending ? "催办中..." : "催办审批"}
</Button>
) : null,
detail.status === "paid" ? (
<Button key="print" asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null,
hasMoreActions ? (
<Button
key="more"
type="button"
variant="secondary"
data-testid="reimbursement-more-toggle"
onClick={() => setShowSecondaryDetail((value) => !value)}
>
{showSecondaryDetail ? "收起更多" : "更多"}
</Button>
) : null,
];
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
standardViolations: precheckStandardChecks.filter((item) => item.exceeded),
budgetCheck: null,
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onAddInvoices: canOpenInvoiceBindDialog ? (item: { id: string }) => {
handleInvoiceDialogOpenChange(true);
setInvoiceUploadLinkedItemId(item.id);
} : undefined,
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
const sourceAttachment = detail.attachments.find((item) => item.id === attachment.id);
if (sourceAttachment) void handleOpenAttachment(sourceAttachment);
},
onOpenMaterialBundle: detail.invoices.length || detail.attachments.length ? () => void handleOpenAllMaterials() : undefined,
onEditItem: canMaintainReimbursement ? (item: { id: string }) => {
const sourceItem = detail.items.find((candidate) => candidate.id === item.id);
if (sourceItem) startEditItem(sourceItem);
} : undefined,
onLinkInvoice: canMaintainReimbursement ? (invoice: { id: string }, linkedItemId: string) => linkInvoiceItemMutation.mutate({ invoiceId: invoice.id, linkedItemId }) : undefined,
linkInvoicePending: linkInvoiceItemMutation.isPending,
linkInvoiceErrors: invoiceItemLinkErrors,
onUnlinkInvoice: canUnbindInvoices ? (invoice: { id: string }) => unbindInvoiceMutation.mutate(invoice.id) : undefined,
onDeleteAttachment: canMaintainReimbursement ? (attachment: { id: string; originalName: string }) =>
setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName }) : undefined,
};
const workspaceHeaderDetails = canMaintainReimbursement || isTravelReimbursement(detail) || detail.project || detail.linkedTravelRequest ? (
<SectionPanel title="补充信息">
{canMaintainReimbursement ? <ReimbursementBatchClassification key={reimbursementId} reimbursementId={reimbursementId} items={detail.items} expenseTypes={expenseTypes} /> : null}
{canMaintainReimbursement ? (
<ReimbursementHeaderEditor reimbursementId={reimbursementId} reimbursementDate={detail.reimbursementDate} description={detail.description} editable showDescription={!isTravelReimbursement(detail)} />
) : null}
<ReimbursementTravelSummary detail={detail} reimbursementId={reimbursementId} editable={canMaintainReimbursement} embedded />
{detail.project ? <p className="mt-3 text-sm text-zinc-600">关联项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
{detail.linkedTravelRequest ? <Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={"/travel-requests?tab=requests&id=" + detail.linkedTravelRequest.id}>差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}</Link> : null}
</SectionPanel>
) : null;
const workspaceBeforeEvidence = (
<>
{invoiceGuardIssues ? <div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3" role="alert" aria-live="assertive"><p className="font-medium text-red-900">票据检查未通过</p><p className="mt-1 text-sm text-red-700">请按明细和票据中的提示处理后再次提交。</p></div> : null}
{actionError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="reimbursement-action-error" role="alert">{actionError}</p> : null}
{itemError ? <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{itemError}</p> : null}
{canMaintainReimbursement ? (
<SectionPanel title="维护报销内容" actions={<div className="flex flex-wrap gap-2"><Button data-testid="reimbursement-item-add" disabled={itemSaveMutation.isPending} onClick={startCreateItem} type="button" variant="secondary">新增明细</Button><Button data-testid={TEST_IDS.reimbursement.invoiceAdd} disabled={!canOpenInvoiceBindDialog} onClick={() => handleInvoiceDialogOpenChange(true)} type="button" variant="secondary">添加发票</Button><label className="inline-flex min-h-11 cursor-pointer items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-700" htmlFor="reimbursement-attachment-upload">选择附件</label><input className="hidden" id="reimbursement-attachment-upload" type="file" accept="application/pdf,image/jpeg,image/png,image/webp" multiple onChange={(event) => { const files = Array.from(event.target.files ?? []); if (files.length > MAX_REIMBURSEMENT_ATTACHMENT_FILES) { setSelectedAttachmentFiles([]); event.target.value = ""; toast.error(`单次最多上传 ${MAX_REIMBURSEMENT_ATTACHMENT_FILES} 个附件,请分批上传。`); return; } setSelectedAttachmentFiles(files); }} /><Button disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending} onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)} type="button" variant="secondary">{uploadAttachmentsMutation.isPending ? "上传中..." : selectedAttachmentFiles.length ? `上传 ${selectedAttachmentFiles.length} 个附件` : "上传附件"}</Button></div>}>
{itemEditor ? <ReimbursementItemEditor mode={itemEditor.mode} expenseTypes={expenseTypes} expenseTypesLoading={expenseTypesQuery.isLoading} expenseTypesError={expenseTypesQuery.isError} departmentId={detail.departmentId} initialValues={editorInitialValues} onSave={handleItemSave} onCancel={handleItemCancel} onDelete={itemEditor.mode === "edit" ? () => setConfirmDeleteItemId(itemEditor.itemId) : undefined} isPending={itemSaveMutation.isPending} /> : <p className="text-sm text-zinc-500">选择上方动作维护明细、发票或补充附件。</p>}
</SectionPanel>
) : null}
</>
);
const workspaceAfterEvidence = (
<>
{(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <SectionPanel title="需要关注">{budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</SectionPanel> : null}
</>
);
const workspaceActionPanel = (
<BusinessDetailActionPanel className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{detail.status !== "paid" ? <Button asChild variant="secondary"><Link to={"/reimbursements/" + detail.id + "/print"}>打印 / PDF</Link></Button> : null}{canDeleteReimbursement ? <Button data-testid="reimbursement-delete-draft" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button> : null}</div> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} submitDisabled={blockStandardChecks.length > 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} />
</BusinessDetailActionPanel>
);
return (
<>
{downloadReasonDialog}
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel, asideContext: <SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" suppressedTargetHref={paymentHandoff?.targetHref} /></SectionPanel> }} />
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent
className="max-h-[85vh] max-w-3xl overflow-y-auto"
data-testid={TEST_IDS.reimbursement.invoiceDialog}
>
<DialogHeader>
<DialogTitle>我的发票</DialogTitle>
<Button type="button" variant="secondary" onClick={() => handleInvoiceDialogOpenChange(false)}>返回报销单</Button>
<DialogDescription>选择可添加发票,或上传新发票。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Input
placeholder="搜索发票号、发票代码或卖方名称"
value={invoiceSearchInput}
onChange={(event) => setInvoiceSearchInput(event.target.value)}
/>
<Button
type="button"
variant="secondary"
disabled={bindableInvoicesQuery.isFetching}
onClick={() => void bindableInvoicesQuery.refetch()}
>
{bindableInvoicesQuery.isFetching ? "刷新中..." : "刷新列表"}
</Button>
</div>
{invoiceActionError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{invoiceActionError}
</p>
) : null}
<div className="max-h-[360px] space-y-3 overflow-y-auto pr-1">
{bindableInvoicesQuery.isLoading ? (
<p className="text-sm text-zinc-500">正在加载可添加发票...</p>
) : bindableInvoicesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}
</p>
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
bindableInvoicesQuery.data?.items.map((item) => {
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
return (
<label
key={item.id}
className={`block rounded-lg border px-4 py-3 ${isSelected ? "border-primary-300 bg-primary-50/50" : "border-zinc-200"}`}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="flex gap-3">
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-zinc-300"
checked={isSelected}
onChange={(event) => toggleInvoiceSelection(item.id, event.target.checked)}
/>
<div className="space-y-1">
<div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
<div className="text-zinc-600">
{[resolveInvoicePartyText(item), item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "", `金额 ${formatMoney(item.totalAmount)}`].filter(Boolean).join(" · ")}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
</div>
</div>
</div>
<div className="min-w-[220px] rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700 md:max-w-[240px]">
点击添加后生成可编辑明细;也可选择加入已有明细。
</div>
</div>
{isSelected && detail.items.length > 0 ? (
<select
aria-label="添加到哪条明细"
className="select-std mt-3 w-full"
value={selectedInvoices[item.id] ?? ""}
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [item.id]: event.target.value }))}
>
<option value="">自动创建新明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
</option>
))}
</select>
) : null}
</label>
);
})
) : (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-500">
当前没有可添加发票,可直接在下方上传新发票。
</div>
)}
</div>
<div className="flex flex-col gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-600">已选 {selectedInvoiceEntries.length} 张发票</div>
<Button
type="button"
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
onClick={handleBindSelectedInvoices}
>
{bindInvoicesMutation.isPending ? "添加中..." : "添加到报销单"}
</Button>
</div>
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium text-zinc-900">或者上传新发票</div>
<p className="text-xs text-zinc-500">仅支持 PDF 发票,可多选。</p>
</div>
<div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center">
<label
htmlFor="reimbursement-inline-invoice-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedInvoiceFiles.length
? selectedInvoiceFiles.length === 1
? selectedInvoiceFiles[0].name
: `已选择 ${selectedInvoiceFiles.length} 个文件`
: "选择发票文件"}
</label>
<input
id="reimbursement-inline-invoice-upload"
className="hidden"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept={PDF_FILE_ACCEPT}
multiple
onChange={(event) => {
handleInvoicePdfSelection(event.target.files);
event.target.value = "";
}}
/>
<Button
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
: "上传发票"}
</Button>
</div>
{detail.items.length > 0 ? (
<select
aria-label="新发票添加位置"
className="select-std mt-3 w-full"
value={invoiceUploadLinkedItemId}
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
>
<option value="">自动创建新明细</option>
{detail.items.map((detailItem) => (
<option key={detailItem.id} value={detailItem.id}>
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
</option>
))}
</select>
) : null}
{selectedInvoiceFiles.length ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
{selectedInvoiceFiles.slice(0, 5).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedInvoiceFiles.length > 5 ? <div>另有 {selectedInvoiceFiles.length - 5} 个文件</div> : null}
</div>
) : null}
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{invoiceOcrTracker.statusMessage}
</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(submitConfirmPrecheck)}
onOpenChange={(open) => {
if (!open) {
setSubmitConfirmPrecheck(null);
}
}}
>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>确认提交报销单?</DialogTitle>
<DialogDescription>以下校验项不会阻止提交,但会作为预警或复核信息进入审批流程。</DialogDescription>
</DialogHeader>
{submitConfirmPrecheck ? (
<div className="space-y-4 text-sm">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 whitespace-pre-wrap">
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
</div>
{submitConfirmPrecheck.standardChecks.some((item) => item.exceeded && item.controlMode === "warn") ? (
<div className="space-y-2">
<h4 className="font-medium text-zinc-900">超出费用标准的明细</h4>
{submitConfirmPrecheck.standardChecks
.filter((item) => item.exceeded && item.controlMode === "warn")
.map((item: ExpenseStandardCheck) => (
<div key={`${item.itemId}-${item.standardId}`} className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-700">
<div className="font-medium">{item.expenseTypeName}</div>
<div>
当前 {formatMoney(item.amount)} / 标准 {formatMoney(item.maxAmount)}/{item.perUnit} / 超出 {formatMoney(item.exceededAmount)}
</div>
<div>{item.standardName}</div>
</div>
))}
</div>
) : null}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setSubmitConfirmPrecheck(null)}>
取消
</Button>
<Button
type="button"
disabled={mutation.isPending}
onClick={() => mutation.mutate({ action: "submit" })}
>
{mutation.isPending ? "提交中..." : "确认提交"}
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(confirmDeleteItemId)}
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
title="确认删除该明细?"
variant="destructive"
confirmLabel="删除"
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
/>
<ConfirmDialog
open={confirmDeleteReimbursement}
onOpenChange={setConfirmDeleteReimbursement}
title={`确认删除 ${displayCode}?`}
description="删除后,此单不再出现在日常列表中,符合条件的发票可重新报销;已发生的审批记录仍保留备查。"
variant="destructive"
confirmLabel="删除报销单"
onConfirm={() => { deleteReimbursementMutation.mutate(); }}
/>
<AttachmentDeleteConfirmDialog
open={Boolean(deleteAttachmentTarget)}
targetName={deleteAttachmentTarget?.originalName}
pending={deleteAttachmentMutation.isPending}
onOpenChange={(open) => {
if (!open) setDeleteAttachmentTarget(null);
}}
onConfirm={(deleteReason) => {
if (!deleteAttachmentTarget) return;
deleteAttachmentMutation.mutate({ attachmentId: deleteAttachmentTarget.id, reason: deleteReason });
}}
/>
</>
);
}