import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { batchApproveWorkflowTasks, batchRejectWorkflowTasks, type WorkflowBatchActionResponse, } from "../api/workflow"; 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, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { MetricCard } from "../components/patterns/MetricCard"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Textarea } from "../components/ui/textarea"; const PAGE_SIZE = 20; type BatchAction = "approve" | "reject"; type ExportFormat = "csv" | "xlsx"; type BatchResultState = { action: BatchAction; summary: WorkflowBatchActionResponse["summary"]; results: Array< WorkflowBatchActionResponse["results"][number] & { reimbursementId: string | null; formNumber: string | null; } >; }; 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 formatBatchOutcome(action: BatchAction, outcome: string) { if (outcome === "approved") { return "已通过"; } if (outcome === "rejected") { return "已驳回"; } if (outcome === "skipped") { return "已跳过"; } if (outcome === "error") { return "失败"; } return action === "approve" ? "已通过" : "已驳回"; } export function ReimbursementsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const { permissions, me } = useAuth(); const queryClient = useQueryClient(); const [exportError, setExportError] = useState(""); const [batchError, setBatchError] = useState(""); const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState(""); const [batchMode, setBatchMode] = useState(false); const [selectedReimbursementIds, setSelectedReimbursementIds] = useState([]); const [batchDialogAction, setBatchDialogAction] = useState(null); const [batchComment, setBatchComment] = useState(""); const [batchResult, setBatchResult] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [exportConfirmFormat, setExportConfirmFormat] = useState(null); 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 canApproveExpense = hasPermission(permissions, "expense.approve"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); const isAdministration = (me?.roleCodes ?? []).includes("administration"); const showProxyOwnerSelector = canProxySubmit && isAdministration; const listQuery = useQuery({ queryKey: ["reimbursements", "list", status, page, scope], queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), }); const proxyDelegationsQuery = useQuery({ enabled: showProxyOwnerSelector, queryKey: ["delegations", "mine", "active"], queryFn: () => fetchMyDelegations("active"), }); const templateOptionsQuery = useQuery({ enabled: canCreateExpense, queryKey: ["reimbursement-templates", "enabled", "create-menu"], queryFn: () => fetchReimbursementTemplates({ enabled: true }), }); const linkableTravelRequestsQuery = useQuery({ enabled: canCreateExpense, 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 actionableItems = useMemo( () => items.filter( (item) => Boolean(item.pendingWorkflow?.task?.id) && Boolean(item.pendingWorkflow?.allowedActions.includes("approve")), ), [items], ); const actionableIdSet = useMemo( () => new Set(actionableItems.map((item) => item.id)), [actionableItems], ); const canShowBatchEntry = status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0; const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id)); const allActionableSelected = actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id)); useEffect(() => { setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id))); if (!canShowBatchEntry) { setBatchMode(false); setBatchDialogAction(null); } }, [actionableIdSet, canShowBatchEntry]); 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 batchMutation = useMutation({ mutationFn: async (input: { action: BatchAction; comment: string; selectedItems: typeof actionableItems }) => { const taskIds = input.selectedItems .map((item) => item.pendingWorkflow?.task?.id ?? null) .filter((taskId): taskId is string => Boolean(taskId)); if (!taskIds.length) { throw new Error("请先选择至少一条可处理的报销待办。"); } const response = input.action === "approve" ? await batchApproveWorkflowTasks({ taskIds, comment: input.comment.trim() || undefined, }) : await batchRejectWorkflowTasks({ taskIds, comment: input.comment.trim(), }); return { action: input.action, response, selectedItems: input.selectedItems.map((item) => ({ reimbursementId: item.id, formNumber: item.formNumber, taskId: item.pendingWorkflow?.task?.id ?? null, })), }; }, onSuccess: async (payload) => { const formByTaskId = new Map( payload.selectedItems .filter((item) => item.taskId) .map((item) => [item.taskId!, item]), ); setBatchResult({ action: payload.action, summary: payload.response.summary, results: payload.response.results.map((item) => { const related = formByTaskId.get(item.taskId); return { ...item, reimbursementId: related?.reimbursementId ?? null, formNumber: related?.formNumber ?? null, }; }), }); setBatchDialogAction(null); setBatchComment(""); setBatchMode(false); setSelectedReimbursementIds([]); setBatchError(""); await queryClient.invalidateQueries({ queryKey: ["reimbursements"] }); toast.success(payload.action === "approve" ? "批量通过已执行" : "批量驳回已执行"); }, onError: (error) => { const message = resolveErrorMessage(error, "批量处理失败"); setBatchError(message); toast.error(message); }, }); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); function toggleBatchSelection(reimbursementId: string) { setSelectedReimbursementIds((current) => current.includes(reimbursementId) ? current.filter((id) => id !== reimbursementId) : [...current, reimbursementId], ); } function toggleSelectAllActionable() { if (allActionableSelected) { setSelectedReimbursementIds([]); return; } setSelectedReimbursementIds(actionableItems.map((item) => item.id)); } function openBatchDialog(action: BatchAction) { if (!selectedItems.length) { setBatchError("请先选择至少一条可批量处理的报销待办。"); return; } setBatchError(""); setBatchDialogAction(action); } function handleCloseBatchDialog(open: boolean) { if (!open) { setBatchDialogAction(null); setBatchComment(""); } } function submitBatchAction() { if (!batchDialogAction) { return; } if (batchDialogAction === "reject" && !batchComment.trim()) { setBatchError("批量驳回必须填写统一理由。"); return; } batchMutation.mutate({ action: batchDialogAction, comment: batchComment, selectedItems, }); } function startCreateReimbursement(payload: Omit = {}) { createMutation.mutate({ ownerEmployeeId: proxyOwnerEmployeeId || undefined, ...payload, }); } return ( {showProxyOwnerSelector ? ( ) : null} {canViewAll ? ( ) : null} {canCreateExpense ? ( startCreateReimbursement()}> 空白报销
从模板创建
{templateOptionsQuery.isLoading ? ( 正在加载模板... ) : templateOptions.length ? ( templateOptions.map((template) => ( startCreateReimbursement({ templateId: template.id })} > {template.name} )) ) : ( 暂无可用模板 )}
关联差旅申请
{linkableTravelRequestsQuery.isLoading ? ( 正在加载差旅申请... ) : linkableTravelRequests.length ? ( linkableTravelRequests.map((travelRequest) => ( startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })} > {travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose} )) ) : ( 暂无可关联的差旅申请 )}
) : null} setExportConfirmFormat("csv")} > 导出 CSV setExportConfirmFormat("xlsx")} > 导出 Excel } /> {createMutation.error && isApiError(createMutation.error) ? (

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

) : null} {templateOptionsQuery.isError ? (

{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}

) : null} {linkableTravelRequestsQuery.isError ? (

{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}

) : null} {exportError ? (

{exportError}

) : null} {batchError ? (

{batchError}

) : null} 0 ? "warning" : "neutral"} />
setSearchParams({ status: value, page: "1", scope })} > {[ { value: "draft", label: "草稿" }, { value: "reviewing", label: "审批中" }, { value: "approved", label: "已通过" }, { value: "paid", label: "已支付" }, { value: "rejected", label: "已驳回" }, { value: "cancelled", label: "已取消" }, ].map((item) => ( {item.label} ))}
{canViewAll ? ( <> ) : null} {canShowBatchEntry ? ( ) : null}
{batchMode ? (
仅勾选你可处理的审批中报销;驳回需填写理由。
) : 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 canSelectRow = Boolean( item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"), ); const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情"; const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; 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 ?? "-"}
{item.pendingWorkflow?.task?.id ? (
待办:{item.pendingWorkflow.task.stepName}
) : null}
{batchMode ? ( ) : null} {canDeleteDraft ? ( ) : null}
); }) ) : (
暂无数据
)}
{batchMode ? : null} {items.length ? ( items.map((item) => { const canSelectRow = Boolean( item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"), ); const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; return ( {batchMode ? ( ) : null} ); }) ) : ( )}
选择单号 状态 金额 报销人 日期 操作
{canSelectRow ? ( toggleBatchSelection(item.id)} aria-label={`选择报销单 ${displayCode}`} /> ) : ( - )}
{displayCode}
{item.project ? (
项目:{item.project.projectNo} · {item.project.name}
) : null} {item.pendingWorkflow?.task?.id ? (
待办:{item.pendingWorkflow.task.stepName}
) : 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 ?? "-"}
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"} {canDeleteDraft ? ( ) : null}
暂无数据
setSearchParams({ status, scope, page: String(p) })} testIdPrefix="reimbursements" />
) : null} {batchMode ? (
已选 {selectedItems.length} 项可处理报销待办
) : null} {batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"} {batchDialogAction === "approve" ? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。" : "驳回必须填写统一理由,所有选中待办将复用这条说明。"}
共 {selectedItems.length} 项:
{selectedItems.map((item) => (
{resolveReimbursementDisplayCode(item)}
))}