1895 行
80 KiB
TypeScript
1895 行
80 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { isApiError } from "../api/types";
|
||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
|
||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||
import { fetchProjects } from "../api/projects";
|
||
import {
|
||
createReimbursementItem,
|
||
deleteReimbursementAttachment,
|
||
deleteReimbursement,
|
||
deleteReimbursementItem,
|
||
downloadReimbursementAttachment,
|
||
fetchReimbursementDetail,
|
||
payReimbursement,
|
||
precheckReimbursement,
|
||
submitReimbursement,
|
||
updateReimbursementItem,
|
||
uploadReimbursementAttachments,
|
||
withdrawReimbursement,
|
||
linkReimbursementProject,
|
||
type ReimbursementItem,
|
||
type ReimbursementStatus,
|
||
} from "../api/reimbursements";
|
||
import { remindWorkflowTask } from "../api/workflow";
|
||
import {
|
||
bindInvoice,
|
||
fetchBindableInvoices,
|
||
unbindInvoice,
|
||
uploadInvoice,
|
||
} from "../api/invoicePool";
|
||
import { toast } from "sonner";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasPermission } from "../auth/permissions";
|
||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||
import { StatusBadge } from "../components/common/StatusBadge";
|
||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||
import { Button } from "../components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "../components/ui/dialog";
|
||
import { Input } from "../components/ui/input";
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||
import {
|
||
formatInvoiceDisplayTitle,
|
||
resolveInvoiceNextStepText,
|
||
resolveInvoiceStatusLabel,
|
||
resolveInvoiceUserMessage,
|
||
} from "../lib/invoiceStatus";
|
||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||
import { TEST_IDS } from "../lib/testIds";
|
||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||
import {
|
||
ReimbursementItemEditor,
|
||
type ItemEditorInitialValues,
|
||
type ItemEditorSaveData,
|
||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||
|
||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||
type SelectedInvoiceMap = Record<string, string>;
|
||
|
||
const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
|
||
draft: "草稿",
|
||
reviewing: "审批中",
|
||
approved: "已通过",
|
||
paid: "已支付",
|
||
rejected: "已驳回",
|
||
cancelled: "已取消",
|
||
};
|
||
|
||
function formatDateTime(value: string | null) {
|
||
if (!value) return "-";
|
||
return value.slice(0, 19).replace("T", " ");
|
||
}
|
||
|
||
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||
if (isApiError(error)) {
|
||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
return error.message;
|
||
}
|
||
|
||
return fallbackMessage;
|
||
}
|
||
|
||
function reimbursementStatusLabel(status: ReimbursementStatus) {
|
||
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
|
||
}
|
||
|
||
function workflowActionLabel(action: string) {
|
||
const labels: Record<string, string> = {
|
||
start: "提交",
|
||
approve: "通过",
|
||
reject: "驳回",
|
||
return: "退回",
|
||
transfer: "转审",
|
||
withdraw: "撤回",
|
||
remind: "催办",
|
||
overdue_remind: "超时提醒",
|
||
};
|
||
return labels[action] ?? action;
|
||
}
|
||
|
||
function resolveErpSyncStatus(
|
||
exportState:
|
||
| {
|
||
exportedAt: string | null;
|
||
lastOutboxStatus: string | null;
|
||
}
|
||
| null,
|
||
) {
|
||
if (!exportState) {
|
||
return "未导出";
|
||
}
|
||
if (exportState.lastOutboxStatus === "pending") {
|
||
return "待导出";
|
||
}
|
||
if (exportState.lastOutboxStatus === "processing") {
|
||
return "导出中";
|
||
}
|
||
if (exportState.lastOutboxStatus === "failed" || exportState.lastOutboxStatus === "dead_letter") {
|
||
return "导出失败";
|
||
}
|
||
if (exportState.lastOutboxStatus === "succeeded" || exportState.exportedAt) {
|
||
return "已导出";
|
||
}
|
||
return "未导出";
|
||
}
|
||
|
||
function budgetControlModeLabel(controlMode: string | null | undefined) {
|
||
const labels: Record<string, string> = {
|
||
block: "超额阻断",
|
||
warn: "超额提醒",
|
||
manual_review: "人工复核",
|
||
};
|
||
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
|
||
}
|
||
|
||
export function ReimbursementDetailPage() {
|
||
const { reimbursementId = "" } = useParams();
|
||
const navigate = useNavigate();
|
||
const { permissions, me, accessToken } = useAuth();
|
||
const queryClient = useQueryClient();
|
||
|
||
const [actionError, setActionError] = useState("");
|
||
|
||
const [itemEditor, setItemEditor] = useState<ItemEditorState>(null);
|
||
const [itemError, setItemError] = useState("");
|
||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||
const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false);
|
||
const [isErpPreviewOpen, setIsErpPreviewOpen] = 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 [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||
expenseTypeId: "",
|
||
expenseDate: "",
|
||
amount: "",
|
||
description: "",
|
||
});
|
||
|
||
const detailQuery = useQuery({
|
||
enabled: Boolean(reimbursementId),
|
||
queryKey: ["reimbursements", reimbursementId],
|
||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||
});
|
||
|
||
const expenseTypesQuery = useQuery({
|
||
queryKey: ["expenseTypes", "enabled"],
|
||
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||
});
|
||
|
||
const projectsQuery = useQuery({
|
||
queryKey: ["projects", "reimbursement-link"],
|
||
queryFn: () => fetchProjects({ mine: true }),
|
||
});
|
||
|
||
const showExternalSyncUi = import.meta.env.VITE_SHOW_ERP_UI === "true";
|
||
const canManageIntegration = hasPermission(permissions, "integration.manage") && showExternalSyncUi;
|
||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
|
||
const canViewOwnInvoice = hasPermission(permissions, "invoice.view_own");
|
||
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
|
||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||
|
||
const reimbursementPrecheckQuery = useQuery({
|
||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||
queryFn: () => precheckReimbursement(reimbursementId),
|
||
});
|
||
|
||
const erpPreviewQuery = useQuery({
|
||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
|
||
queryFn: () => previewReimbursementJournal(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 } | { action: "pay"; payload: PayPayload }) => {
|
||
if (!reimbursementId) {
|
||
throw new Error("missing reimbursementId");
|
||
}
|
||
|
||
if (params.action === "submit") {
|
||
return submitReimbursement(reimbursementId);
|
||
}
|
||
|
||
if (params.action === "withdraw") {
|
||
return withdrawReimbursement(reimbursementId, params.comment);
|
||
}
|
||
|
||
return payReimbursement(reimbursementId, {
|
||
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
|
||
paymentDate: params.payload.paymentDate || undefined,
|
||
paymentChannel: params.payload.paymentChannel,
|
||
paymentNote: params.payload.paymentNote || undefined,
|
||
});
|
||
},
|
||
onSuccess: async (_data, params) => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||
]);
|
||
setActionError("");
|
||
if (params.action === "submit") {
|
||
setSubmitConfirmPrecheck(null);
|
||
}
|
||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
||
toast.success(actionMessages[params.action] ?? "操作成功");
|
||
},
|
||
onError: (error) => {
|
||
if (isApiError(error)) {
|
||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
setActionError(msg);
|
||
toast.error(msg);
|
||
return;
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
setActionError(error.message);
|
||
toast.error(error.message);
|
||
return;
|
||
}
|
||
|
||
setActionError("操作失败,请稍后重试");
|
||
toast.error("操作失败,请稍后重试");
|
||
},
|
||
});
|
||
|
||
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,
|
||
expenseDate: data.expenseDate,
|
||
amount: data.amount,
|
||
description: data.description.trim() || undefined,
|
||
};
|
||
|
||
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("");
|
||
toast.success("明细已保存");
|
||
},
|
||
onError: (error) => {
|
||
if (isApiError(error)) {
|
||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
setItemError(msg);
|
||
toast.error(msg);
|
||
return;
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
setItemError(error.message);
|
||
toast.error(error.message);
|
||
return;
|
||
}
|
||
|
||
setItemError("保存明细失败,请稍后重试");
|
||
toast.error("保存明细失败,请稍后重试");
|
||
},
|
||
});
|
||
|
||
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"] }),
|
||
]);
|
||
setItemError("");
|
||
toast.success("明细已删除");
|
||
},
|
||
onError: (error) => {
|
||
if (isApiError(error)) {
|
||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
setItemError(msg);
|
||
toast.error(msg);
|
||
return;
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
setItemError(error.message);
|
||
toast.error(error.message);
|
||
return;
|
||
}
|
||
|
||
setItemError("删除明细失败,请稍后重试");
|
||
toast.error("删除明细失败,请稍后重试");
|
||
},
|
||
});
|
||
|
||
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 projectLinkMutation = useMutation({
|
||
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||
]);
|
||
toast.success("已更新关联项目");
|
||
},
|
||
onError: (error) => {
|
||
const message = isApiError(error)
|
||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||
: error instanceof Error
|
||
? error.message
|
||
: "关联项目失败";
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const erpExportMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (!reimbursementId) {
|
||
throw new Error("missing reimbursementId");
|
||
}
|
||
return exportReimbursementJournal(reimbursementId, true);
|
||
},
|
||
onSuccess: async (result) => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||
]);
|
||
setActionError("");
|
||
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
|
||
toast.success(`财务桥接出队${suffix}`);
|
||
},
|
||
onError: (error) => {
|
||
if (isApiError(error)) {
|
||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
setActionError(msg);
|
||
toast.error(msg);
|
||
return;
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
setActionError(error.message);
|
||
toast.error(error.message);
|
||
return;
|
||
}
|
||
|
||
setActionError("财务桥接出队失败,请稍后重试");
|
||
toast.error("财务桥接出队失败,请稍后重试");
|
||
},
|
||
});
|
||
|
||
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 } : {}),
|
||
});
|
||
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))),
|
||
);
|
||
|
||
if (!failedResults.length) {
|
||
setInvoiceActionError("");
|
||
setIsInvoiceDialogOpen(false);
|
||
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;
|
||
}
|
||
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
|
||
},
|
||
[bindInvoicesMutation],
|
||
);
|
||
|
||
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);
|
||
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("");
|
||
toast.success("发票已移除");
|
||
},
|
||
onError: (error) => {
|
||
const message = buildActionErrorMessage(error, "移除发票失败");
|
||
setInvoiceActionError(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[] = [];
|
||
let boundCount = 0;
|
||
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())])),
|
||
);
|
||
|
||
for (const invoiceId of availableIds) {
|
||
try {
|
||
await bindInvoice(invoiceId, { reimbursementId });
|
||
boundCount += 1;
|
||
} catch (error) {
|
||
bindFailures.push(`新上传发票:${buildActionErrorMessage(error, "添加发票失败")}`);
|
||
}
|
||
}
|
||
|
||
return {
|
||
uploadedCount: files.length - uploadFailures.length,
|
||
uploadFailures,
|
||
boundCount,
|
||
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.boundCount || 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("请选择附件材料");
|
||
}
|
||
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: string) => deleteReimbursementAttachment(reimbursementId, attachmentId),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
|
||
toast.success("附件材料已删除");
|
||
},
|
||
onError: (error) => {
|
||
toast.error(buildActionErrorMessage(error, "删除附件失败"));
|
||
},
|
||
});
|
||
|
||
const openAttachmentMutation = useMutation({
|
||
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
|
||
onSuccess: (result) => {
|
||
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, "打开附件失败"));
|
||
},
|
||
});
|
||
|
||
if (!reimbursementId) {
|
||
return (
|
||
<div className="card-std p-6">
|
||
<p className="text-sm text-red-600">缺少 reimbursementId 参数。</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (detailQuery.isLoading) {
|
||
return <CardSkeleton lines={8} />;
|
||
}
|
||
|
||
if (detailQuery.isError || !detailQuery.data) {
|
||
const message = isApiError(detailQuery.error)
|
||
? `${detailQuery.error.code}: ${mapApiErrorMessage(
|
||
detailQuery.error.code,
|
||
detailQuery.error.details,
|
||
detailQuery.error.message,
|
||
)}`
|
||
: "加载报销详情失败";
|
||
|
||
return (
|
||
<div className="card-std space-y-3 p-6">
|
||
<p className="text-sm text-red-600">{message}</p>
|
||
<Button asChild variant="secondary">
|
||
<Link to="/reimbursements">返回报销列表</Link>
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const detail = detailQuery.data;
|
||
const payment = detail.payment ?? {
|
||
paymentVoucherNo: null,
|
||
paymentDate: null,
|
||
paymentChannel: null,
|
||
paidAt: null,
|
||
paidBy: null,
|
||
};
|
||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||
const isPaidReimbursement = detail.status === "paid";
|
||
const erpSyncStatus = resolveErpSyncStatus(erpPreviewQuery.data?.exportState ?? null);
|
||
const budgetHint = detail.budgetHint;
|
||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
||
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 = canEditItems && (isReimbursementParticipant || canViewAllExpense);
|
||
const canDeleteReimbursement =
|
||
detail.status === "draft" &&
|
||
canDeleteExpense &&
|
||
(isReimbursementParticipant || canViewAllExpense);
|
||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||
const missingInvoiceSubmitMessage = "提交前需先添加至少 1 张发票。";
|
||
const submitInvoiceNotice = canEditItems && detail.invoices.length < 1 ? missingInvoiceSubmitMessage : "";
|
||
const selectedInvoiceEntries = Object.entries(selectedInvoices);
|
||
|
||
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?.expenseTypeId ?? expenseTypes[0]?.id ?? "";
|
||
|
||
return {
|
||
expenseDate: defaultDate,
|
||
expenseTypeId: defaultExpenseTypeId,
|
||
amount: item?.amount ?? "",
|
||
description: item?.description ?? "",
|
||
};
|
||
};
|
||
|
||
const startCreateItem = () => {
|
||
setItemEditor({ mode: "create" });
|
||
setEditorInitialValues(computeEditorDefaults());
|
||
setItemError("");
|
||
};
|
||
|
||
const startEditItem = (item: ReimbursementItem) => {
|
||
setItemEditor({ mode: "edit", itemId: item.id });
|
||
setEditorInitialValues(computeEditorDefaults(item));
|
||
setItemError("");
|
||
};
|
||
|
||
const handleItemSave = (data: ItemEditorSaveData) => {
|
||
itemSaveMutation.mutate(data);
|
||
};
|
||
|
||
const handleItemCancel = () => {
|
||
setItemEditor(null);
|
||
setItemError("");
|
||
};
|
||
|
||
const handleSubmitAction = async () => {
|
||
if (!reimbursementId) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setActionError("");
|
||
if (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) {
|
||
if (isApiError(error)) {
|
||
const message = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
setActionError(message);
|
||
toast.error(message);
|
||
return;
|
||
}
|
||
|
||
if (error instanceof Error) {
|
||
setActionError(error.message);
|
||
toast.error(error.message);
|
||
return;
|
||
}
|
||
|
||
setActionError("提交前校验失败,请稍后重试");
|
||
toast.error("提交前校验失败,请稍后重试");
|
||
}
|
||
};
|
||
|
||
const handleInvoiceDialogOpenChange = (open: boolean) => {
|
||
setIsInvoiceDialogOpen(open);
|
||
if (!open) {
|
||
setInvoiceSearchInput("");
|
||
setInvoiceSearch("");
|
||
setSelectedInvoices({});
|
||
setSelectedInvoiceFiles([]);
|
||
setInvoiceActionError("");
|
||
setPendingOcrInvoiceIds([]);
|
||
}
|
||
};
|
||
|
||
const toggleInvoiceSelection = (invoiceId: string, checked: boolean) => {
|
||
setSelectedInvoices((previous) => {
|
||
if (!checked) {
|
||
const next = { ...previous };
|
||
delete next[invoiceId];
|
||
return next;
|
||
}
|
||
|
||
return {
|
||
...previous,
|
||
[invoiceId]: previous[invoiceId] ?? "",
|
||
};
|
||
});
|
||
};
|
||
|
||
const handleBindSelectedInvoices = () => {
|
||
if (!selectedInvoiceEntries.length) {
|
||
return;
|
||
}
|
||
|
||
bindInvoicesMutation.mutate(
|
||
selectedInvoiceEntries.map(([invoiceId]) => ({
|
||
invoiceId,
|
||
linkedItemId: undefined,
|
||
})),
|
||
);
|
||
};
|
||
|
||
const resolveLinkedItemLabel = (linkedItemId: string | null) => {
|
||
if (!linkedItemId) {
|
||
return "自动生成或匹配明细";
|
||
}
|
||
|
||
const linkedItem = detail.items.find((item) => item.id === linkedItemId);
|
||
if (!linkedItem) {
|
||
return "未识别明细";
|
||
}
|
||
|
||
return `${linkedItem.expenseType?.name ?? "未识别费用类型"} · ${linkedItem.description ?? "未填写说明"}`;
|
||
};
|
||
|
||
return (
|
||
<PageScaffold>
|
||
<EntityOverviewHero
|
||
title={`报销详情 ${displayCode}`}
|
||
subtitle="明细、发票和审批。"
|
||
meta={(
|
||
<>
|
||
<StatusBadge status={detail.status} />
|
||
<span className="info-chip">报销日期:{detail.reimbursementDate ?? "-"}</span>
|
||
<span className="info-chip">报销人:{detail.owner?.name ?? "-"}</span>
|
||
<span className="info-chip">提交人:{detail.submitter?.name ?? "-"}</span>
|
||
<span className="info-chip">金额:{detail.totalAmount ?? "-"}</span>
|
||
</>
|
||
)}
|
||
actions={(
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button asChild variant="secondary">
|
||
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
|
||
</Button>
|
||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||
<Link to="/reimbursements">返回列表</Link>
|
||
</Button>
|
||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||
<Button
|
||
data-testid="reimbursement-remind-workflow"
|
||
disabled={remindMutation.isPending}
|
||
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||
type="button"
|
||
variant="secondary"
|
||
>
|
||
{remindMutation.isPending ? "催办中..." : "催办审批"}
|
||
</Button>
|
||
) : null}
|
||
{canDeleteReimbursement ? (
|
||
<Button
|
||
data-testid="reimbursement-delete-draft"
|
||
disabled={deleteReimbursementMutation.isPending}
|
||
onClick={() => setConfirmDeleteReimbursement(true)}
|
||
type="button"
|
||
variant="destructive"
|
||
>
|
||
删除草稿
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
/>
|
||
|
||
<div className="detail-shell-grid">
|
||
<div className="space-y-6">
|
||
<SectionPanel title="概览与关联">
|
||
<div className="space-y-6" data-testid="reimbursement-overview">
|
||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||
<div className="text-zinc-500">当前状态</div>
|
||
<div className="mt-2"><StatusBadge status={detail.status} /></div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||
<div className="text-zinc-500">关联项目</div>
|
||
<div className="mt-2 font-medium text-zinc-900">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||
<div className="text-zinc-500">明细数</div>
|
||
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||
<div className="text-zinc-500">发票</div>
|
||
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
||
<div>状态:{reimbursementStatusLabel(detail.status)}</div>
|
||
<div>报销编号:{displayCode}</div>
|
||
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||
<div>说明:{detail.description || "-"}</div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-payment-status">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
|
||
<p className="text-xs text-zinc-500">付款备注写入审计日志,凭证、日期和操作人用于财务台账核对。</p>
|
||
</div>
|
||
<span
|
||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||
detail.status === "paid"
|
||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||
: "border-amber-200 bg-amber-50 text-amber-700"
|
||
}`}
|
||
>
|
||
{detail.status === "paid" ? "已付款" : "未付款"}
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">付款日期</div>
|
||
<div className="mt-1 font-medium text-zinc-900">{payment.paymentDate ?? "-"}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">付款凭证号</div>
|
||
<div className="mt-1 font-medium text-zinc-900">{payment.paymentVoucherNo ?? "-"}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">付款渠道</div>
|
||
<div className="mt-1 font-medium text-zinc-900">{payment.paymentChannel ?? "-"}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">操作人 / 时间</div>
|
||
<div className="mt-1 font-medium text-zinc-900">
|
||
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{detail.linkedTravelRequest ? (
|
||
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<h4 className="text-base font-semibold text-zinc-900">
|
||
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
|
||
</h4>
|
||
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
|
||
</div>
|
||
<Link
|
||
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
|
||
className="text-sm text-primary hover:underline"
|
||
>
|
||
查看申请
|
||
</Link>
|
||
</div>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">日期范围</div>
|
||
<div className="mt-1 font-medium text-zinc-900">
|
||
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">预估金额</div>
|
||
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||
<div className="text-xs text-zinc-500">申请编号</div>
|
||
<div className="mt-1 font-medium text-zinc-900">
|
||
{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
|
||
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
|
||
</div>
|
||
{detail.project ? (
|
||
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
|
||
查看项目
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
<select
|
||
className="select-std mt-4 max-w-xl"
|
||
value={detail.projectId ?? ""}
|
||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||
disabled={projectLinkMutation.isPending}
|
||
>
|
||
<option value="">不关联项目</option>
|
||
{(projectsQuery.data?.items ?? []).map((item) => (
|
||
<option key={item.id} value={item.id}>
|
||
{item.projectNo} · {item.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 text-sm">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<h3 className="text-base font-semibold text-zinc-900">附件材料</h3>
|
||
<p className="text-xs text-zinc-500">支付截图、合同、订单或情况说明,不参与发票识别。</p>
|
||
</div>
|
||
{canEditItems ? (
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||
<label
|
||
htmlFor="reimbursement-attachment-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"
|
||
>
|
||
{selectedAttachmentFiles.length
|
||
? selectedAttachmentFiles.length === 1
|
||
? selectedAttachmentFiles[0].name
|
||
: `已选择 ${selectedAttachmentFiles.length} 个文件`
|
||
: "选择附件"}
|
||
</label>
|
||
<input
|
||
id="reimbursement-attachment-upload"
|
||
className="hidden"
|
||
type="file"
|
||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||
multiple
|
||
onChange={(event) => setSelectedAttachmentFiles(Array.from(event.target.files ?? []))}
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
disabled={!selectedAttachmentFiles.length || uploadAttachmentsMutation.isPending}
|
||
onClick={() => uploadAttachmentsMutation.mutate(selectedAttachmentFiles)}
|
||
>
|
||
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{(detail.attachments ?? []).length ? (
|
||
<div className="space-y-2">
|
||
{(detail.attachments ?? []).map((attachment) => (
|
||
<div key={attachment.id} className="flex flex-col gap-3 rounded-lg border border-zinc-200 px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
||
<div className="text-xs text-zinc-500">
|
||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={openAttachmentMutation.isPending}
|
||
onClick={() => openAttachmentMutation.mutate(attachment.id)}
|
||
>
|
||
查看
|
||
</Button>
|
||
{canEditItems ? (
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
size="sm"
|
||
disabled={deleteAttachmentMutation.isPending}
|
||
onClick={() => deleteAttachmentMutation.mutate(attachment.id)}
|
||
>
|
||
删除
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-zinc-500">暂无附件材料</p>
|
||
)}
|
||
</div>
|
||
</SectionPanel>
|
||
|
||
<SectionPanel title="明细与票据">
|
||
<div className="space-y-6">
|
||
<div className="space-y-4" data-testid="reimbursement-items">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
|
||
<p className="text-xs text-zinc-500">明细变更会自动重算总金额。</p>
|
||
</div>
|
||
|
||
{canEditItems ? (
|
||
<Button
|
||
data-testid="reimbursement-item-add"
|
||
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
|
||
onClick={startCreateItem}
|
||
type="button"
|
||
variant="secondary"
|
||
>
|
||
新增明细
|
||
</Button>
|
||
) : (
|
||
<p className="text-xs text-zinc-500">当前状态不可编辑明细</p>
|
||
)}
|
||
</div>
|
||
|
||
{itemError ? (
|
||
<p
|
||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||
data-testid="reimbursement-item-error"
|
||
>
|
||
{itemError}
|
||
</p>
|
||
) : null}
|
||
|
||
{reimbursementPrecheckQuery.isLoading && canEditItems ? (
|
||
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
|
||
) : null}
|
||
|
||
<div className="rounded-lg border border-zinc-200">
|
||
<Table data-testid="reimbursement-items-table">
|
||
<TableHeader>
|
||
<tr>
|
||
<TableHead>费用类型</TableHead>
|
||
<TableHead>发生日期</TableHead>
|
||
<TableHead>金额</TableHead>
|
||
<TableHead>说明</TableHead>
|
||
<TableHead className="w-44">操作</TableHead>
|
||
</tr>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{detail.items.length ? (
|
||
detail.items.map((item) => (
|
||
<TableRow
|
||
data-testid="reimbursement-item-row"
|
||
key={item.id}
|
||
className={
|
||
blockStandardChecks.some((check) => check.itemId === item.id)
|
||
? "bg-red-50/70"
|
||
: warnStandardChecks.some((check) => check.itemId === item.id)
|
||
? "bg-amber-50/70"
|
||
: ""
|
||
}
|
||
>
|
||
<TableCell>
|
||
<div className="font-medium text-zinc-900">
|
||
{item.expenseType?.name ?? "未识别费用类型"}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
||
<TableCell>
|
||
<div>{item.amount}</div>
|
||
{precheckStandardChecks
|
||
.filter((check) => check.itemId === item.id)
|
||
.map((check) => (
|
||
<div
|
||
key={check.standardId}
|
||
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
|
||
>
|
||
标准 ¥{check.maxAmount}/{check.perUnit}
|
||
{check.exceeded ? `,超出 ¥${check.exceededAmount}` : ""}
|
||
</div>
|
||
))}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div>{item.description ?? "-"}</div>
|
||
{precheckStandardChecks
|
||
.filter((check) => check.itemId === item.id && check.exceeded)
|
||
.map((check) => (
|
||
<div
|
||
key={`${check.standardId}-warning`}
|
||
className={`mt-1 text-xs ${check.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}
|
||
>
|
||
{check.standardName}:当前金额超出费用标准。
|
||
</div>
|
||
))}
|
||
</TableCell>
|
||
<TableCell>
|
||
{canEditItems ? (
|
||
<div className="flex min-w-36 items-center gap-2">
|
||
<Button
|
||
data-testid="reimbursement-item-edit"
|
||
className="min-w-16"
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => startEditItem(item)}
|
||
>
|
||
编辑
|
||
</Button>
|
||
<Button
|
||
data-testid="reimbursement-item-delete"
|
||
className="min-w-16"
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
disabled={itemDeleteMutation.isPending}
|
||
onClick={() => setConfirmDeleteItemId(item.id)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
<TableRow>
|
||
<TableCell className="py-3 text-zinc-500" colSpan={5}>
|
||
暂无明细
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{itemEditor ? (
|
||
<ReimbursementItemEditor
|
||
mode={itemEditor.mode}
|
||
expenseTypes={expenseTypes}
|
||
expenseTypesLoading={expenseTypesQuery.isLoading}
|
||
expenseTypesError={expenseTypesQuery.isError}
|
||
departmentId={detail.departmentId}
|
||
initialValues={editorInitialValues}
|
||
onSave={handleItemSave}
|
||
onCancel={handleItemCancel}
|
||
isPending={itemSaveMutation.isPending}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div className="space-y-1">
|
||
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
|
||
</div>
|
||
{canOpenInvoiceBindDialog ? (
|
||
<Button
|
||
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => handleInvoiceDialogOpenChange(true)}
|
||
>
|
||
添加发票
|
||
</Button>
|
||
) : canEditItems ? (
|
||
<p className="text-xs text-zinc-500">当前账号不可在此页添加发票</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{invoiceActionError ? (
|
||
<p
|
||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||
data-testid={TEST_IDS.reimbursement.invoiceActionError}
|
||
>
|
||
{invoiceActionError}
|
||
</p>
|
||
) : null}
|
||
|
||
{(detail.invoices ?? []).length ? (
|
||
<div className="space-y-3">
|
||
{detail.invoices.map((item) => {
|
||
const invoiceHint = resolveInvoiceNextStepText(item);
|
||
return (
|
||
<div
|
||
className="rounded-lg border border-zinc-200 px-4 py-3"
|
||
key={item.id}
|
||
>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div className="space-y-1">
|
||
<div className="font-medium text-zinc-900">
|
||
{formatInvoiceDisplayTitle(item)}
|
||
</div>
|
||
<div className="text-zinc-600">
|
||
{item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">
|
||
原始文件:{item.originalName || "未记录原始文件"}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">
|
||
明细:{resolveLinkedItemLabel(item.linkedItemId)}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col items-start gap-2 md:items-end">
|
||
<div className="flex items-center gap-2">
|
||
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
|
||
{resolveInvoiceStatusLabel(item.status)}
|
||
</span>
|
||
{canUnbindInvoices ? (
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
size="sm"
|
||
disabled={unbindInvoiceMutation.isPending}
|
||
onClick={() => unbindInvoiceMutation.mutate(item.id)}
|
||
>
|
||
移除
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
{invoiceHint ? (
|
||
<div className="max-w-[260px] text-xs text-zinc-500 md:text-right">{invoiceHint}</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-zinc-500">暂无发票</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</SectionPanel>
|
||
|
||
<SectionPanel title="预算与审核提示">
|
||
<div className="space-y-6">
|
||
{budgetHint ? (
|
||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
|
||
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
||
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
|
||
<div>
|
||
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
|
||
({budgetHint.nextUsagePercent.toFixed(2)}%)
|
||
</div>
|
||
<div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
|
||
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
|
||
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
|
||
当前金额将超出预算上限,提交时会被阻断。
|
||
</p>
|
||
) : budgetHint.exceedsThreshold ? (
|
||
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
|
||
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
|
||
</p>
|
||
) : (
|
||
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
|
||
|
||
{canManageIntegration ? (
|
||
<SectionPanel
|
||
title="财务导出状态"
|
||
description="付款后可生成财务导出记录。"
|
||
actions={
|
||
<div className="flex gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
disabled={!isPaidReimbursement || erpPreviewQuery.isLoading || Boolean(erpPreviewQuery.isError) || !erpPreviewQuery.data}
|
||
onClick={() => setIsErpPreviewOpen(true)}
|
||
>
|
||
预览凭证
|
||
</Button>
|
||
<Button type="button" disabled={!isPaidReimbursement || erpExportMutation.isPending} onClick={() => erpExportMutation.mutate()}>
|
||
{erpExportMutation.isPending ? "导出中..." : "生成导出"}
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
{!isPaidReimbursement ? (
|
||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||
未满足导出条件,仅已付款报销可生成财务导出记录。
|
||
</p>
|
||
) : null}
|
||
|
||
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载财务凭证预览...</p> : null}
|
||
{erpPreviewQuery.isError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{isApiError(erpPreviewQuery.error)
|
||
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
|
||
erpPreviewQuery.error.code,
|
||
erpPreviewQuery.error.details,
|
||
erpPreviewQuery.error.message,
|
||
)}`
|
||
: "加载财务凭证预览失败"}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4" data-testid="reimbursement-erp-export">
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||
<div className="text-xs text-zinc-500">导出状态</div>
|
||
<div className="mt-1 font-medium text-zinc-900">{erpSyncStatus}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||
<div className="text-xs text-zinc-500">最近导出</div>
|
||
<div className="mt-1 font-medium text-zinc-900">{formatDateTime(erpPreviewQuery.data?.exportState.exportedAt ?? null)}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||
<div className="text-xs text-zinc-500">导出任务</div>
|
||
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.lastOutboxId ?? "-"}</div>
|
||
</div>
|
||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||
<div className="text-xs text-zinc-500">外部凭证号</div>
|
||
<div className="mt-1 break-all font-medium text-zinc-900">{erpPreviewQuery.data?.exportState.externalId ?? "-"}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{erpPreviewQuery.data?.exportState.lastError ? (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
最近错误:{erpPreviewQuery.data.exportState.lastError}
|
||
</div>
|
||
) : null}
|
||
</SectionPanel>
|
||
) : null}
|
||
</div>
|
||
</SectionPanel>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<DetailSideMeta title="报销摘要">
|
||
<div>报销编号:{displayCode}</div>
|
||
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
|
||
<div>明细数:{detail.items.length}</div>
|
||
<div>票据数:{detail.invoices.length}</div>
|
||
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||
</DetailSideMeta>
|
||
|
||
{detail.status === "reviewing" && detail.workflowProgress ? (
|
||
<DetailSideMeta
|
||
title="审批进度"
|
||
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
|
||
>
|
||
<div className="space-y-3" data-testid={TEST_IDS.reimbursement.workflowProgress}>
|
||
{detail.workflowProgress.steps.map((step, index) => (
|
||
<div
|
||
key={step.id}
|
||
className={
|
||
step.status === "current"
|
||
? "rounded-lg border border-primary-200 bg-primary-50 px-3 py-2"
|
||
: step.status === "completed"
|
||
? "rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2"
|
||
: "rounded-lg border border-zinc-200 bg-white px-3 py-2"
|
||
}
|
||
>
|
||
<div className="flex items-center justify-between gap-3 text-sm">
|
||
<span className="font-medium text-zinc-900">
|
||
第 {index + 1} 步 · {step.name}
|
||
</span>
|
||
<span className="text-xs text-zinc-500">
|
||
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</DetailSideMeta>
|
||
) : null}
|
||
|
||
<DetailSideMeta title="审批记录">
|
||
{detail.approvalHistory.length ? (
|
||
<div className="space-y-3" data-testid="reimbursement-approval-history">
|
||
{detail.approvalHistory.map((item) => (
|
||
<div key={item.id} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
|
||
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
|
||
</div>
|
||
<div className="mt-1 text-xs text-zinc-500">
|
||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||
</div>
|
||
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-zinc-500">暂无审批记录。</div>
|
||
)}
|
||
</DetailSideMeta>
|
||
|
||
<ReimbursementActions
|
||
status={detail.status}
|
||
canPay={canPay}
|
||
actionError={actionError}
|
||
submitDisabled={blockStandardChecks.length > 0}
|
||
submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""}
|
||
submitNotice={submitInvoiceNotice}
|
||
isPending={mutation.isPending}
|
||
onSubmit={() => void handleSubmitAction()}
|
||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
<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-600">
|
||
{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-600">
|
||
{buildActionErrorMessage(bindableInvoicesQuery.error, "加载可添加发票失败")}
|
||
</p>
|
||
) : (bindableInvoicesQuery.data?.items ?? []).length ? (
|
||
bindableInvoicesQuery.data?.items.map((item) => {
|
||
const isSelected = Object.prototype.hasOwnProperty.call(selectedInvoices, item.id);
|
||
const invoiceHint = resolveInvoiceNextStepText({ ...item, status: "available" });
|
||
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">
|
||
{item.sellerName || "未识别销售方"} · {item.invoiceDate || "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
|
||
</div>
|
||
<div className="text-xs text-zinc-500">
|
||
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(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]">
|
||
{invoiceHint} 添加后自动生成或匹配报销明细。
|
||
</div>
|
||
</div>
|
||
</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="application/pdf"
|
||
multiple
|
||
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
|
||
/>
|
||
<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>
|
||
|
||
{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={isErpPreviewOpen} onOpenChange={setIsErpPreviewOpen}>
|
||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>财务凭证预览</DialogTitle>
|
||
<DialogDescription>查看凭证内容和最近导出状态。</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{erpPreviewQuery.data ? (
|
||
<div className="grid gap-4 text-sm lg:grid-cols-2">
|
||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||
<h4 className="font-medium text-zinc-900">凭证切片</h4>
|
||
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
|
||
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
|
||
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
|
||
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
|
||
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
|
||
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
|
||
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
|
||
</div>
|
||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
||
<div>导出状态:{erpSyncStatus}</div>
|
||
<div>外部凭证号:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||
<div>任务编号:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||
<div>任务状态:{erpSyncStatus}</div>
|
||
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
||
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-zinc-500">暂无可预览的财务凭证数据。</p>
|
||
)}
|
||
</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>
|
||
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} / 超出 ¥{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(); }}
|
||
/>
|
||
</PageScaffold>
|
||
);
|
||
}
|