import { useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchMyDelegations } from "../api/delegations"; import { exportReimbursements } from "../api/exports"; import { fetchReimbursementTemplates } from "../api/reimbursementTemplates"; import { createReimbursement, deleteReimbursement, fetchLinkableTravelRequests, fetchReimbursements, type ReimbursementCreatePayload, type ReimbursementListItem, type ReimbursementStatus, } from "../api/reimbursements"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { toast } from "sonner"; import { downloadBlob } from "../lib/download"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { Pagination } from "../components/common/Pagination"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { Button } from "../components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; const PAGE_SIZE = 20; type ExportFormat = "csv" | "xlsx"; const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [ { value: "draft", label: "草稿" }, { value: "rejected", label: "被驳回" }, { value: "reviewing", label: "审批中" }, { value: "approved", label: "已通过" }, { value: "paid", label: "已支付" }, { value: "cancelled", label: "已取消" }, ]; const REIMBURSEMENT_TASK_STATUS_OPTIONS = [ { value: "rejected", title: "被驳回待补正", idleNote: "暂无需要补正的报销", activeNote: "优先补齐说明或附件后重新提交", icon: RotateCcw, accentClass: "border-red-200 bg-red-50 text-red-700", }, { value: "draft", title: "草稿待完善", idleNote: "还没有报销草稿", activeNote: "继续完善明细、发票和附件", icon: FilePenLine, accentClass: "border-amber-200 bg-amber-50 text-amber-700", }, { value: "reviewing", title: "审批中进度", idleNote: "暂无审批中的报销", activeNote: "查看当前节点和处理进度", icon: Clock3, accentClass: "border-sky-200 bg-sky-50 text-sky-700", }, ] satisfies Array<{ value: ReimbursementStatus; title: string; idleNote: string; activeNote: string; icon: typeof RotateCcw; accentClass: string; }>; function formatCurrency(value: number) { return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value); } function resolveErrorMessage(error: unknown, fallback: string) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } return error instanceof Error ? error.message : fallback; } function resolveEmptyStateCopy(status: ReimbursementStatus) { if (status === "draft") { return { title: "还没有报销草稿", description: "需要报销时先新建单据,再补充明细、发票和附件。", }; } if (status === "rejected") { return { title: "暂无需要补正的报销", description: "后续有驳回单据时,会在这里继续编辑并重新提交。", }; } if (status === "reviewing") { return { title: "暂无审批中的报销", description: "提交后的报销会在这里显示当前审批进度。", }; } if (status === "approved") { return { title: "暂无已通过记录", description: "审批通过但未支付的报销会在这里留存。", }; } if (status === "paid") { return { title: "暂无已支付记录", description: "财务支付完成后会在这里查看付款结果。", }; } return { title: "暂无已取消记录", description: "撤回或取消的报销记录会在这里留存。", }; } function resolveWorkflowHint(item: ReimbursementListItem) { const stepName = item.pendingWorkflow?.task?.stepName; if (!stepName) { return null; } const actionableByCurrentUser = item.pendingWorkflow?.allowedActions.length ? true : false; return { label: `${actionableByCurrentUser ? "待你处理" : "当前节点"}:${stepName}`, className: actionableByCurrentUser ? "text-amber-700" : "text-zinc-500", }; } function resolveListAction(item: ReimbursementListItem) { if (["draft", "rejected"].includes(item.status)) { return { label: "编辑", mobileLabel: "继续编辑", to: `/reimbursements/${item.id}`, primary: true, }; } const taskId = item.pendingWorkflow?.task?.id; if (taskId && item.pendingWorkflow?.allowedActions.length) { return { label: "查看处理", mobileLabel: "查看处理", to: `/workflow/tasks/${taskId}`, primary: false, }; } return { label: item.status === "reviewing" ? "查看进度" : "详情", mobileLabel: item.status === "reviewing" ? "查看进度" : "查看详情", to: `/reimbursements/${item.id}`, primary: false, }; } export function ReimbursementsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const { permissions, me } = useAuth(); const queryClient = useQueryClient(); const [exportError, setExportError] = useState(""); const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState(""); const [deleteTarget, setDeleteTarget] = useState(null); const [exportConfirmFormat, setExportConfirmFormat] = useState(null); const [showLedgerTools, setShowLedgerTools] = useState(false); const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft"; const page = Number(searchParams.get("page") ?? "1") || 1; const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own"; const canCreateExpense = hasPermission(permissions, "expense.create"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canViewAll = hasPermission(permissions, "expense.view_all"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const canUseFinanceTools = hasPermission(permissions, "expense.finance.pay") || hasPermission(permissions, "payment.batch.manage") || hasPermission(permissions, "risk.review.manage") || hasPermission(permissions, "invoice.manage"); const isAdministration = (me?.roleCodes ?? []).includes("administration"); const showProxyOwnerSelector = canProxySubmit && isAdministration; const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools; const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector); const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense; const listQuery = useQuery({ queryKey: ["reimbursements", "list", status, page, scope], queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), }); const rejectedSummaryQuery = useQuery({ queryKey: ["reimbursements", "task-summary", "own", "rejected"], queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }), }); const draftSummaryQuery = useQuery({ queryKey: ["reimbursements", "task-summary", "own", "draft"], queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }), }); const reviewingSummaryQuery = useQuery({ queryKey: ["reimbursements", "task-summary", "own", "reviewing"], queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }), }); const proxyDelegationsQuery = useQuery({ enabled: showProxyOwnerSelector, queryKey: ["delegations", "mine", "active"], queryFn: () => fetchMyDelegations("active"), }); const templateOptionsQuery = useQuery({ enabled: shouldLoadCreateOptions, queryKey: ["reimbursement-templates", "enabled", "create-menu"], queryFn: () => fetchReimbursementTemplates({ enabled: true }), }); const linkableTravelRequestsQuery = useQuery({ enabled: shouldLoadCreateOptions, queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"], queryFn: () => fetchLinkableTravelRequests({ ownerEmployeeId: proxyOwnerEmployeeId || undefined, }), }); const items = listQuery.data?.items ?? []; const stats = useMemo(() => { return items.reduce( (acc, item) => { acc.currentPageCount += 1; acc.currentPageAmount += Number(item.totalAmount ?? 0); if (item.status === "reviewing") acc.reviewing += 1; if (item.status === "paid") acc.paid += 1; return acc; }, { currentPageCount: 0, currentPageAmount: 0, reviewing: 0, paid: 0 }, ); }, [items]); const templateOptions = templateOptionsQuery.data?.items ?? []; const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? []; const taskSummaryTotalByStatus = { rejected: rejectedSummaryQuery.data?.total, draft: draftSummaryQuery.data?.total, reviewing: reviewingSummaryQuery.data?.total, } satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>; const canShowCreateOptionsMenu = canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0); const emptyState = resolveEmptyStateCopy(status); const createMutation = useMutation({ mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload), onSuccess: async (response, payload) => { await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); if (payload.linkedTravelRequestId) { toast.success("已按差旅申请创建报销单"); } else if (payload.templateId) { toast.success("已按模板创建报销单"); } else { toast.success("报销单已创建"); } navigate(`/reimbursements/${response.id}`); }, onError: (error) => { toast.error(resolveErrorMessage(error, "创建报销单失败")); }, }); const deleteMutation = useMutation({ mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); toast.success("草稿已删除"); }, onError: (error) => { toast.error(resolveErrorMessage(error, "删除草稿失败")); }, }); const handleDeleteDraft = (item: ReimbursementListItem) => { setDeleteTarget(item); }; const exportMutation = useMutation({ mutationFn: (format: ExportFormat) => exportReimbursements({ format, status, scope }), onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`); setExportError(""); toast.success("导出完成"); }, onError: (error) => { const message = resolveErrorMessage(error, "导出失败"); setExportError(message); toast.error(message); }, }); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); function startCreateReimbursement(payload: Omit = {}) { createMutation.mutate({ ownerEmployeeId: proxyOwnerEmployeeId || undefined, ...payload, }); } return ( startCreateReimbursement()} type="button" > {createMutation.isPending ? "创建中..." : "新建报销"} ) : null } /> {createMutation.error && isApiError(createMutation.error) ? (

{createMutation.error.code}: {mapApiErrorMessage( createMutation.error.code, createMutation.error.details, createMutation.error.message, )}

) : null} {exportError ? (

{exportError}

) : null}
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => { const Icon = item.icon; const count = taskSummaryTotalByStatus[item.value]; const active = status === item.value && scope === "own"; const displayCount = typeof count === "number" ? count : "-"; return ( ); })}
报销记录
setSearchParams({ status: value, page: "1", scope })} > {REIMBURSEMENT_STATUS_OPTIONS.map((item) => ( {item.label} ))}
{canShowLedgerEntry ? ( ) : null}
{showAdvancedTools ? (
当前列表
{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条
当前金额
{formatCurrency(stats.currentPageAmount)}
审批中
0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条
已支付
{stats.paid} 条
{showProxyOwnerSelector ? ( ) : null} {canShowCreateOptionsMenu ? (
从模板创建
{templateOptions.length ? ( templateOptions.map((template) => ( startCreateReimbursement({ templateId: template.id })} > {template.name} )) ) : ( 当前无可用模板 )}
关联差旅申请
{linkableTravelRequests.length ? ( linkableTravelRequests.map((travelRequest) => ( startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })} > {travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose} )) ) : ( 当前无可关联申请 )}
) : null} {canViewAll ? ( <> ) : null} setExportConfirmFormat("csv")} > 导出 CSV setExportConfirmFormat("xlsx")} > 导出 Excel
) : null}
{listQuery.isLoading ? : null} {listQuery.isError ? (

{isApiError(listQuery.error) ? `${listQuery.error.code}: ${mapApiErrorMessage( listQuery.error.code, listQuery.error.details, listQuery.error.message, )}` : "加载报销列表失败"}

) : null} {!listQuery.isLoading && !listQuery.isError ? ( <>
{items.length ? ( items.map((item) => { const listAction = resolveListAction(item); const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; const workflowHint = resolveWorkflowHint(item); return (
{displayCode}
{item.reimbursementDate ?? "未填写日期"}
{item.project ? (
项目:{item.project.projectNo} · {item.project.name}
) : null}
金额
{item.totalAmount != null ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format( Number(item.totalAmount), ) : "-"}
报销人
{item.owner?.name ?? "-"}
{workflowHint ? (
{workflowHint.label}
) : null}
{canDeleteDraft ? ( ) : null}
); }) ) : (
{emptyState.title}
{emptyState.description}
{status === "draft" && canCreateExpense ? ( ) : null}
)}
{items.length ? ( items.map((item) => { const listAction = resolveListAction(item); const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; const workflowHint = resolveWorkflowHint(item); return ( ); }) ) : ( )}
单号 状态 金额 报销人 日期 操作
{displayCode}
{item.project ? (
项目:{item.project.projectNo} · {item.project.name}
) : null} {workflowHint ? (
{workflowHint.label}
) : null}
{item.totalAmount != null ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format( Number(item.totalAmount), ) : "-"}
{(item.owner?.name ?? "U").charAt(0)}
{item.owner?.name ?? "-"}
{item.reimbursementDate ?? "-"}
{canDeleteDraft ? ( handleDeleteDraft(item)} > 删除草稿 ) : null}
{emptyState.title}
{emptyState.description}
{status === "draft" && canCreateExpense ? ( ) : null}
setSearchParams({ status, scope, page: String(p) })} testIdPrefix="reimbursements" />
) : null} { if (!open) setDeleteTarget(null); }} kind="delete" targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined} description="删除后无法恢复,已提交或审批中的报销单不会受到影响。" onConfirm={async () => { if (deleteTarget) { deleteMutation.mutate(deleteTarget.id); } }} /> { if (!open) setExportConfirmFormat(null); }} kind="sensitiveExport" targetName={exportConfirmFormat === "xlsx" ? "报销 Excel" : "报销 CSV"} onConfirm={async () => { if (exportConfirmFormat) { exportMutation.mutate(exportConfirmFormat); } }} />
); }