import { useState } from "react"; import { Link, 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 { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations"; import { createReimbursementItem, deleteReimbursementItem, fetchReimbursementDetail, payReimbursement, submitReimbursement, updateReimbursementItem, withdrawReimbursement, type ReimbursementItem, } from "../api/reimbursements"; 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 { PageHeader } from "../components/common/PageHeader"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { Button } from "../components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions"; import { ReimbursementItemEditor, type ItemEditorInitialValues, type ItemEditorSaveData, } from "./reimbursement-detail/ReimbursementItemEditor"; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; function formatDateTime(value: string | null) { if (!value) return "-"; return value.slice(0, 19).replace("T", " "); } export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const { permissions } = useAuth(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(""); const [itemEditor, setItemEditor] = useState(null); const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [editorInitialValues, setEditorInitialValues] = useState({ 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 canManageIntegration = hasPermission(permissions, "integration.manage"); const erpPreviewQuery = useQuery({ enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid", queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"], queryFn: () => previewReimbursementJournal(reimbursementId), }); 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, }); }, onSuccess: async (_data, params) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), ]); setActionError(""); const actionMessages: Record = { 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 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] }), ]); 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] }), ]); 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 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, "erp-journal-preview"] }), queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), ]); setActionError(""); const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队"; toast.success(`ERP 导出${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("ERP 导出失败,请稍后重试"); toast.error("ERP 导出失败,请稍后重试"); }, }); if (!reimbursementId) { return (

缺少 reimbursementId 参数。

); } if (detailQuery.isLoading) { return ; } if (detailQuery.isError || !detailQuery.data) { const message = isApiError(detailQuery.error) ? `${detailQuery.error.code}: ${mapApiErrorMessage( detailQuery.error.code, detailQuery.error.details, detailQuery.error.message, )}` : "加载报销详情失败"; return (

{message}

); } const detail = detailQuery.data; const budgetHint = detail.budgetHint; const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); 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(""); }; return (
返回列表 } />
报销ID:{detail.id}
报销日期:{detail.reimbursementDate ?? "-"}
金额:{detail.totalAmount ?? "-"}
提交人:{detail.submitter?.name ?? "-"}
报销人:{detail.owner?.name ?? "-"}
说明:{detail.description || "-"}
{budgetHint ? (

预算提示

预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount} ({budgetHint.nextUsagePercent.toFixed(2)}%)
控制模式:{budgetHint.controlMode}
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (

当前金额将超出预算上限,提交时会被阻断(block 模式)。

) : budgetHint.exceedsThreshold ? (

当前金额已达到预算预警阈值,提交后会记录预算预警审计。

) : (

当前预算充足,可继续提交。

)}
) : null} {canManageIntegration && detail.status === "paid" ? (

ERP 导出

已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。

{erpPreviewQuery.isLoading ?

正在加载凭证切片预览...

: null} {erpPreviewQuery.isError ? (

{isApiError(erpPreviewQuery.error) ? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage( erpPreviewQuery.error.code, erpPreviewQuery.error.details, erpPreviewQuery.error.message, )}` : "加载 ERP 导出预览失败"}

) : null} {erpPreviewQuery.data ? (

凭证切片

类型:{erpPreviewQuery.data.journal.journalType}
单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}
金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}
法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}
成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}
付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}
付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}

导出状态

外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}
Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}
队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}
最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}
最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}
) : null}
) : null}

报销明细

明细变更会自动重算总金额。

{canEditItems ? ( ) : (

当前状态不可编辑明细

)}
{itemError ? (

{itemError}

) : null}
费用类型发生日期金额说明操作 {detail.items.length ? ( detail.items.map((item) => (
{item.expenseType?.name ?? item.expenseTypeId}
{item.expenseType?.code ?? "-"}
{item.expenseDate ?? "-"} {item.amount} {item.description ?? "-"} {canEditItems ? (
) : null}
)) ) : ( 暂无明细 )}
{itemEditor ? ( ) : null}
mutation.mutate({ action: "submit" })} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} onPay={(payload) => mutation.mutate({ action: "pay", payload })} />

关联票据

{(detail.invoices ?? []).length ? (
{detail.invoices.map((item) => (
{item.id}
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
))}
) : (

暂无关联票据

)}
{ if (!open) setConfirmDeleteItemId(""); }} title="确认删除该明细?" variant="destructive" confirmLabel="删除" onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }} />
); }