import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { toast } from "sonner"; import { fetchProjects } from "../api/projects"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { createTravelRequest, fetchTravelRequestDetail, fetchTravelRequests, precheckTravelRequest, submitTravelRequest, updateTravelRequest, withdrawTravelRequest, linkTravelRequestProject, type TravelRequestDetail, type TravelRequestPayload, type TravelRequestStatus, type TravelSegmentRole, type TravelTransportType, type TravelTripType, } from "../api/travelRequests"; import { fetchCostCenters, fetchLegalEntities } from "../api/masterData"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { CardSkeleton } from "../components/common/CardSkeleton"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { EmptyStateCard } from "../components/common/EmptyStateCard"; import { PageHeader } from "../components/common/PageHeader"; import { WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { Pagination } from "../components/common/Pagination"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { Button } from "../components/ui/button"; import { TravelPaymentTrackCard } from "../components/travel/TravelPaymentTrackCard"; import { TravelReconciliationTab } from "../components/travel/TravelReconciliationTab"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Textarea } from "../components/ui/textarea"; import { buildPrecheckDialogMessage } from "../lib/precheck"; const PAGE_SIZE = 12; type DraftSegment = { localId: string; segmentRole: TravelSegmentRole; transportType: TravelTransportType; fromCity: string; toCity: string; departAt: string; arriveAt: string; }; function resolveError(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } function emptySegment(): DraftSegment { return { localId: crypto.randomUUID(), segmentRole: "outbound", transportType: "flight", fromCity: "", toCity: "", departAt: "", arriveAt: "", }; } function toDateTimeLocalValue(value: string | null) { if (!value) { return ""; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return ""; } const pad = (input: number) => String(input).padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function toApiDateTime(value: string) { if (!value) { return value; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toISOString(); } function tripTypeLabel(value: TravelTripType) { return value === "international" ? "国际" : "国内"; } function segmentRoleLabel(value: TravelSegmentRole) { if (value === "outbound") return "去程"; if (value === "return") return "返程"; return "中转"; } function transportTypeLabel(value: TravelTransportType) { if (value === "flight") return "航班"; if (value === "train") return "火车"; if (value === "car") return "汽车"; if (value === "bus") return "大巴"; return "其他"; } function buildPayload(values: { purpose: string; tripType: TravelTripType; startDate: string; endDate: string; legalEntityId: string; costCenterId: string; estimatedAmount: string; segments: DraftSegment[]; }): TravelRequestPayload { return { purpose: values.purpose.trim(), tripType: values.tripType, startDate: values.startDate, endDate: values.endDate, legalEntityId: values.legalEntityId, costCenterId: values.costCenterId, estimatedAmount: values.estimatedAmount, segments: values.segments.map((item) => ({ segmentRole: item.segmentRole, transportType: item.transportType, fromCity: item.fromCity.trim(), toCity: item.toCity.trim(), departAt: toApiDateTime(item.departAt), arriveAt: toApiDateTime(item.arriveAt), })), }; } function buildPatchPayload(values: Parameters[0]) { return buildPayload(values); } function buildDraftSegments(detail: TravelRequestDetail | null): DraftSegment[] { if (!detail?.segments?.length) { return [emptySegment()]; } return detail.segments.map((item) => ({ localId: item.id, segmentRole: item.segmentRole, transportType: item.transportType, fromCity: item.fromCity, toCity: item.toCity, departAt: toDateTimeLocalValue(item.departAt), arriveAt: toDateTimeLocalValue(item.arriveAt), })); } export function TravelRequestsPage() { const queryClient = useQueryClient(); const { permissions } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const canCreate = hasPermission(permissions, "travel.create"); const canViewOwn = hasPermission(permissions, "travel.view_own"); const canViewAll = hasPermission(permissions, "travel.view_all") || hasPermission(permissions, "travel.manage"); const canAccessRequestsTab = canCreate || canViewOwn || canViewAll; const canAccessReconciliationTab = hasPermission(permissions, "travel.payment.manage") || hasPermission(permissions, "integration.manage"); const requestedTab = searchParams.get("tab"); const activeTab = requestedTab === "reconciliation" ? canAccessReconciliationTab ? "reconciliation" : "requests" : canAccessRequestsTab ? "requests" : "reconciliation"; const [status, setStatus] = useState(""); const [mine, setMine] = useState(true); const [tripType, setTripType] = useState(""); const [legalEntityFilter, setLegalEntityFilter] = useState(""); const [costCenterFilter, setCostCenterFilter] = useState(""); const [page, setPage] = useState(1); const [selectedId, setSelectedId] = useState(searchParams.get("id") ?? ""); const [formError, setFormError] = useState(""); const [purpose, setPurpose] = useState(""); const [formTripType, setFormTripType] = useState("domestic"); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); const [legalEntityId, setLegalEntityId] = useState(""); const [costCenterId, setCostCenterId] = useState(""); const [estimatedAmount, setEstimatedAmount] = useState("0.00"); const [segments, setSegments] = useState([emptySegment()]); const [submitPrecheckConfirm, setSubmitPrecheckConfirm] = useState(null); const selectedQueryId = searchParams.get("id") ?? ""; const selectedBatchId = searchParams.get("batchId") ?? ""; useEffect(() => { const defaultTab = canAccessRequestsTab ? "requests" : "reconciliation"; if (requestedTab === activeTab && requestedTab) { return; } const next = new URLSearchParams(searchParams); next.set("tab", activeTab || defaultTab); setSearchParams(next, { replace: true }); }, [activeTab, canAccessRequestsTab, requestedTab, searchParams, setSearchParams]); useEffect(() => { setSelectedId(selectedQueryId); }, [selectedQueryId]); const legalEntitiesQuery = useQuery({ enabled: canAccessRequestsTab && activeTab === "requests", queryKey: ["travel-requests", "legal-entities"], queryFn: () => fetchLegalEntities({ enabled: true }), }); const costCentersQuery = useQuery({ enabled: canAccessRequestsTab && activeTab === "requests", queryKey: ["travel-requests", "cost-centers"], queryFn: () => fetchCostCenters({ enabled: true }), }); const listQuery = useQuery({ enabled: canAccessRequestsTab && activeTab === "requests", queryKey: ["travel-requests", "list", status, mine, tripType, legalEntityFilter, costCenterFilter, page], queryFn: () => fetchTravelRequests({ status: status || undefined, mine: canViewAll ? mine : true, tripType: tripType || undefined, legalEntityId: legalEntityFilter || undefined, costCenterId: costCenterFilter || undefined, page, pageSize: PAGE_SIZE, }), }); const detailQuery = useQuery({ enabled: canAccessRequestsTab && activeTab === "requests" && Boolean(selectedId), queryKey: ["travel-requests", "detail", selectedId], queryFn: () => fetchTravelRequestDetail(selectedId), }); const projectsQuery = useQuery({ enabled: canAccessRequestsTab && activeTab === "requests", queryKey: ["projects", "travel-link"], queryFn: () => fetchProjects({ mine: true }), }); const selectableCostCenters = useMemo(() => { return (costCentersQuery.data?.items ?? []).filter((item) => { if (!legalEntityId) { return true; } return item.legalEntityId === legalEntityId; }); }, [costCentersQuery.data?.items, legalEntityId]); const filterableCostCenters = useMemo(() => { return (costCentersQuery.data?.items ?? []).filter((item) => { if (!legalEntityFilter) { return true; } return item.legalEntityId === legalEntityFilter; }); }, [costCentersQuery.data?.items, legalEntityFilter]); useEffect(() => { if (detailQuery.data) { setPurpose(detailQuery.data.purpose ?? ""); setFormTripType(detailQuery.data.tripType); setStartDate(detailQuery.data.startDate ?? ""); setEndDate(detailQuery.data.endDate ?? ""); setLegalEntityId(detailQuery.data.legalEntityId ?? ""); setCostCenterId(detailQuery.data.costCenterId ?? ""); setEstimatedAmount(detailQuery.data.estimatedAmount ?? "0.00"); setSegments(buildDraftSegments(detailQuery.data)); setFormError(""); return; } if (!selectedId) { setPurpose(""); setFormTripType("domestic"); setStartDate(""); setEndDate(""); setLegalEntityId(""); setCostCenterId(""); setEstimatedAmount("0.00"); setSegments([emptySegment()]); setFormError(""); } }, [detailQuery.data, selectedId]); useEffect(() => { if (activeTab !== "requests" || selectedId || canCreate) { return; } const nextId = listQuery.data?.items?.[0]?.id ?? ""; if (!nextId) { return; } const next = new URLSearchParams(searchParams); next.set("id", nextId); setSearchParams(next); }, [activeTab, canCreate, listQuery.data?.items, searchParams, selectedId, setSearchParams]); const detail = detailQuery.data ?? null; const isDraftEditing = Boolean(detail && detail.status === "draft" && detail.permissions.canEditDraft); const handleSubmitAction = async () => { if (!selectedId) { return; } try { setFormError(""); validateForm(); if (isDraftEditing) { await updateTravelRequest( selectedId, buildPatchPayload({ purpose, tripType: formTripType, startDate, endDate, legalEntityId, costCenterId, estimatedAmount, segments, }), ); } const precheck = await precheckTravelRequest(selectedId); const message = buildPrecheckDialogMessage(precheck); if (precheck.decision === "block") { setFormError(message); toast.error("提交前校验未通过"); return; } if (precheck.decision === "warn" || precheck.decision === "manual_review") { setSubmitPrecheckConfirm(message); return; } submitMutation.mutate(); } catch (error) { const message = resolveError(error); setFormError(message); toast.error(message); } }; function switchTab(nextTab: "requests" | "reconciliation") { const next = new URLSearchParams(searchParams); next.set("tab", nextTab); setSearchParams(next); } function selectTravelRequest(nextId: string) { setSelectedId(nextId); const next = new URLSearchParams(searchParams); next.set("tab", "requests"); if (nextId) { next.set("id", nextId); } else { next.delete("id"); } setSearchParams(next); } function selectTravelReconciliationBatch(nextId: string) { const next = new URLSearchParams(searchParams); next.set("tab", "reconciliation"); if (nextId) { next.set("batchId", nextId); } else { next.delete("batchId"); } setSearchParams(next); } function validateForm() { if (!canCreate && !selectedId) { throw new Error("当前无可保存的差旅申请"); } if (!purpose.trim()) { throw new Error("请填写出差目的"); } if (!startDate || !endDate) { throw new Error("请选择出差起止日期"); } if (!legalEntityId || !costCenterId) { throw new Error("请选择法体与成本中心"); } if (!estimatedAmount || Number(estimatedAmount) < 0) { throw new Error("请填写有效的预估金额"); } if (!segments.length) { throw new Error("至少需要一条行程段"); } for (const item of segments) { if (!item.fromCity.trim() || !item.toCity.trim()) { throw new Error("请补全行程段的出发地与目的地"); } if (!item.departAt || !item.arriveAt) { throw new Error("请补全行程段的出发与到达时间"); } } } const projectLinkMutation = useMutation({ mutationFn: (projectId: string | null) => linkTravelRequestProject(selectedId, projectId), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["travel-requests"] }), queryClient.invalidateQueries({ queryKey: ["travel-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["projects"] }), queryClient.invalidateQueries({ queryKey: ["portal-home"] }), ]); toast.success("已更新关联项目"); }, onError: (error) => toast.error(resolveError(error)), }); const saveMutation = useMutation({ mutationFn: async () => { validateForm(); const values = { purpose, tripType: formTripType, startDate, endDate, legalEntityId, costCenterId, estimatedAmount, segments }; if (selectedId && isDraftEditing) { return updateTravelRequest(selectedId, buildPatchPayload(values)); } return createTravelRequest(buildPayload(values)); }, onSuccess: async (travelRequest) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["travel-requests"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), ]); setFormError(""); selectTravelRequest(travelRequest.id); toast.success(selectedId && isDraftEditing ? "差旅草稿已更新" : "差旅草稿已创建"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const submitMutation = useMutation({ mutationFn: async () => { if (!selectedId) { throw new Error("请选择待提交的差旅申请"); } return submitTravelRequest(selectedId); }, onSuccess: async (data) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["travel-requests"] }), queryClient.invalidateQueries({ queryKey: ["travel-requests", "detail", selectedId] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), ]); toast.success( data.precheckDecision === "manual_review" ? `差旅申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险` : "差旅申请已提交", ); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const withdrawMutation = useMutation({ mutationFn: async () => { if (!selectedId) { throw new Error("请选择待撤回的差旅申请"); } return withdrawTravelRequest(selectedId); }, onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["travel-requests"] }), queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), ]); toast.success("差旅申请已撤回"); }, onError: (error) => { const message = resolveError(error); setFormError(message); toast.error(message); }, }); const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); return ( <>
selectTravelRequest("")}>新建差旅申请 ) : undefined } /> 差旅不是本次 7 天试运行主路径;如需使用,仅按已有权限查看或维护,不扩展新流程。
{canAccessRequestsTab ? ( ) : null} {canAccessReconciliationTab ? ( ) : null}
{activeTab === "requests" ? ( <> {formError ? (

{formError}

) : null}
{canViewAll ? (
) : null}
{listQuery.isLoading ? : null} {listQuery.isError ? (

{resolveError(listQuery.error)}

) : null} {!listQuery.isLoading && !listQuery.isError ? (
{(listQuery.data?.items ?? []).length ? ( (listQuery.data?.items ?? []).map((item) => ( )) ) : ( )}
) : null}
{selectedId && detailQuery.isLoading ? : null} {selectedId && detailQuery.isError ? (
{resolveError(detailQuery.error)}
) : null} {!selectedId ? ( canCreate ? (

新建差旅申请

填写主单与行程段,保存为草稿后可继续编辑并提交审批。

) : ( ) ) : null} {detail ? (

{detail.requestNo ?? "差旅草稿"}

{detail.purpose}

{detail.permissions.canSubmit ? ( ) : null} {detail.permissions.canWithdraw ? ( ) : null} {isDraftEditing ? ( ) : null}
关联项目
差旅申请可选挂到项目,用于费用和出差统一汇总。
{detail.project ? 查看项目 : null}

申请人

{detail.applicant?.name ?? "-"}

出差类型

{tripTypeLabel(detail.tripType)}

起止日期

{detail.startDate ?? "-"} → {detail.endDate ?? "-"}

预算池

{detail.budgetPool?.name ?? "待提交时自动解析"}

法体

{detail.legalEntity?.name ?? "-"}

成本中心

{detail.costCenter?.name ?? "-"}

预估金额

{detail.estimatedAmount}

提交人

{detail.submitter?.name ?? "-"}

审批轨道

{detail.pendingWorkflow ? (
实例:{detail.pendingWorkflow.instanceId}
状态:
当前步骤:{detail.pendingWorkflow.task?.stepName ?? detail.pendingWorkflow.currentStepId}
审批角色:{detail.pendingWorkflow.task?.assigneeRole ?? "-"}
) : (

当前没有进行中的审批实例。

)}

关联差旅订单

{detail.travelOrders.length ? (
{detail.travelOrders.map((item) => (
{item.externalOrderNo}
来源:{item.source}
发生日期:{item.occurredAt ?? "-"}
金额:{item.amount ?? "-"} {item.currency}
报销关联:{item.linkedReimbursementId ?? "-"}
付款渠道:{item.paymentChannel ?? "-"} · 付款日期:{item.paymentDate ?? "-"}
))}
) : (

当前暂无关联的 `travel_orders` 入站记录。

)}
) : null}
) : canAccessReconciliationTab ? ( ) : ( )}
{ if (!open) setSubmitPrecheckConfirm(null); }} kind="precheck" targetName={detail?.requestNo ?? "差旅申请"} description={submitPrecheckConfirm} onConfirm={async () => { submitMutation.mutate(); }} /> ); } type TravelRequestFormProps = { purpose: string; setPurpose: (value: string) => void; tripType: TravelTripType; setTripType: (value: TravelTripType) => void; startDate: string; setStartDate: (value: string) => void; endDate: string; setEndDate: (value: string) => void; legalEntityId: string; setLegalEntityId: (value: string) => void; legalEntities: Array<{ id: string; name: string; code: string }>; costCenterId: string; setCostCenterId: (value: string) => void; costCenters: Array<{ id: string; name: string; code: string }>; estimatedAmount: string; setEstimatedAmount: (value: string) => void; segments: DraftSegment[]; setSegments: Dispatch>; readOnly: boolean; budgetPoolName: string; }; function TravelRequestForm(props: TravelRequestFormProps) { const { purpose, setPurpose, tripType, setTripType, startDate, setStartDate, endDate, setEndDate, legalEntityId, setLegalEntityId, legalEntities, costCenterId, setCostCenterId, costCenters, estimatedAmount, setEstimatedAmount, segments, setSegments, readOnly, budgetPoolName, } = props; return (