文件
hyb-oa/client/src/pages/TravelRequestsPage.tsx
T
wangsli 9905c1c79d feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3): 落地内部OA够用版前端入口与运营页面
关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
2026-03-21 18:03:48 +08:00

1131 行
42 KiB
TypeScript

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 { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
createTravelRequest,
fetchTravelRequestDetail,
fetchTravelRequests,
precheckTravelRequest,
submitTravelRequest,
updateTravelRequest,
withdrawTravelRequest,
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 { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
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<typeof buildPayload>[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<TravelRequestStatus | "">("");
const [mine, setMine] = useState(true);
const [tripType, setTripType] = useState<TravelTripType | "">("");
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<TravelTripType>("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<DraftSegment[]>([emptySegment()]);
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 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") && !window.confirm(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 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 (
<div className="space-y-6">
<PageHeader
title="差旅管理"
subtitle="差旅申请、对公支付轨道与批次对账在同一路径统一收口。"
actions={
activeTab === "requests" && canCreate ? (
<Button type="button" onClick={() => selectTravelRequest("")}>新建差旅申请</Button>
) : undefined
}
/>
<div className="flex flex-wrap gap-2">
{canAccessRequestsTab ? (
<Button
type="button"
variant={activeTab === "requests" ? "default" : "secondary"}
onClick={() => switchTab("requests")}
>
差旅申请
</Button>
) : null}
{canAccessReconciliationTab ? (
<Button
type="button"
variant={activeTab === "reconciliation" ? "default" : "secondary"}
onClick={() => switchTab("reconciliation")}
>
对账
</Button>
) : null}
</div>
{activeTab === "requests" ? (
<>
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{formError}</p>
) : null}
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<section className="card-std space-y-4 p-5">
<div className="grid gap-3">
<div>
<Label htmlFor="travel-filter-status">状态</Label>
<select
id="travel-filter-status"
className="select-std mt-1"
value={status}
onChange={(event) => {
setStatus(event.target.value as TravelRequestStatus | "");
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
</select>
</div>
{canViewAll ? (
<div>
<Label htmlFor="travel-filter-scope">范围</Label>
<select
id="travel-filter-scope"
className="select-std mt-1"
value={mine ? "mine" : "all"}
onChange={(event) => {
setMine(event.target.value !== "all");
setPage(1);
}}
>
<option value="mine">仅看我的</option>
<option value="all">查看全部</option>
</select>
</div>
) : null}
<div>
<Label htmlFor="travel-filter-trip-type">出差类型</Label>
<select
id="travel-filter-trip-type"
className="select-std mt-1"
value={tripType}
onChange={(event) => {
setTripType(event.target.value as TravelTripType | "");
setPage(1);
}}
>
<option value="">全部类型</option>
<option value="domestic">国内</option>
<option value="international">国际</option>
</select>
</div>
<div>
<Label htmlFor="travel-filter-legal-entity">法体</Label>
<select
id="travel-filter-legal-entity"
className="select-std mt-1"
value={legalEntityFilter}
onChange={(event) => {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
setPage(1);
}}
>
<option value="">全部法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="travel-filter-cost-center">成本中心</Label>
<select
id="travel-filter-cost-center"
className="select-std mt-1"
value={costCenterFilter}
onChange={(event) => {
setCostCenterFilter(event.target.value);
setPage(1);
}}
>
<option value="">全部成本中心</option>
{filterableCostCenters.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div>
</div>
{listQuery.isLoading ? <TableSkeleton columns={1} rows={6} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(listQuery.error)}</p>
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<div className="space-y-3">
{(listQuery.data?.items ?? []).length ? (
(listQuery.data?.items ?? []).map((item) => (
<button
key={item.id}
type="button"
onClick={() => selectTravelRequest(item.id)}
className={`w-full rounded-xl border px-4 py-3 text-left transition ${
item.id === selectedId ? "border-primary bg-primary-50" : "border-zinc-200 hover:border-zinc-300"
}`}
data-testid={`travel-request-item-${item.id}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-zinc-900">{item.requestNo ?? item.purpose}</p>
<p className="mt-1 text-xs text-zinc-500 line-clamp-2">{item.purpose}</p>
</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-2 space-y-1 text-xs text-zinc-500">
<div>{tripTypeLabel(item.tripType)} · {item.startDate ?? "-"} → {item.endDate ?? "-"}</div>
<div>预估金额:{item.estimatedAmount}</div>
<div>申请人:{item.applicant?.name ?? "-"}</div>
</div>
</button>
))
) : (
<EmptyStateCard
title="暂无差旅申请"
description={canCreate ? "点击右上角创建新的差旅草稿。" : "当前筛选条件下没有可查看的差旅申请。"}
/>
)}
</div>
) : null}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} testIdPrefix="travel-requests" />
</section>
<section className="space-y-6">
{selectedId && detailQuery.isLoading ? <CardSkeleton lines={10} /> : null}
{selectedId && detailQuery.isError ? (
<div className="card-std p-6 text-sm text-red-600">{resolveError(detailQuery.error)}</div>
) : null}
{!selectedId ? (
canCreate ? (
<div className="card-std space-y-6 p-6">
<div>
<h3 className="text-lg font-semibold text-zinc-900">新建差旅申请</h3>
<p className="mt-1 text-sm text-zinc-500">填写主单与行程段,保存为草稿后可继续编辑并提交审批。</p>
</div>
<TravelRequestForm
purpose={purpose}
setPurpose={setPurpose}
tripType={formTripType}
setTripType={setFormTripType}
startDate={startDate}
setStartDate={setStartDate}
endDate={endDate}
setEndDate={setEndDate}
legalEntityId={legalEntityId}
setLegalEntityId={setLegalEntityId}
legalEntities={legalEntitiesQuery.data?.items ?? []}
costCenterId={costCenterId}
setCostCenterId={setCostCenterId}
costCenters={selectableCostCenters}
estimatedAmount={estimatedAmount}
setEstimatedAmount={setEstimatedAmount}
segments={segments}
setSegments={setSegments}
readOnly={false}
budgetPoolName="待提交时自动解析"
/>
<div className="flex justify-end">
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} data-testid="travel-request-save">
{saveMutation.isPending ? "保存中..." : "保存草稿"}
</Button>
</div>
</div>
) : (
<EmptyStateCard title="请选择差旅申请" description="从左侧选择一条差旅申请查看详情。" />
)
) : null}
{detail ? (
<div className="space-y-6">
<div className="card-std space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-zinc-900">{detail.requestNo ?? "差旅草稿"}</h3>
<StatusBadge status={detail.status} />
</div>
<p className="mt-1 text-sm text-zinc-500">{detail.purpose}</p>
</div>
<div className="flex flex-wrap gap-2">
{detail.permissions.canSubmit ? (
<Button
type="button"
variant="secondary"
onClick={() => void handleSubmitAction()}
disabled={submitMutation.isPending}
data-testid="travel-request-submit"
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
) : null}
{detail.permissions.canWithdraw ? (
<Button
type="button"
variant="secondary"
onClick={() => withdrawMutation.mutate()}
disabled={withdrawMutation.isPending}
data-testid="travel-request-withdraw"
>
{withdrawMutation.isPending ? "撤回中..." : "撤回到草稿"}
</Button>
) : null}
{isDraftEditing ? (
<Button type="button" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} data-testid="travel-request-update">
{saveMutation.isPending ? "保存中..." : "保存修改"}
</Button>
) : null}
</div>
</div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
<div>
<p className="text-zinc-500">申请人</p>
<p className="font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</p>
</div>
<div>
<p className="text-zinc-500">出差类型</p>
<p className="font-medium text-zinc-900">{tripTypeLabel(detail.tripType)}</p>
</div>
<div>
<p className="text-zinc-500">起止日期</p>
<p className="font-medium text-zinc-900">{detail.startDate ?? "-"} → {detail.endDate ?? "-"}</p>
</div>
<div>
<p className="text-zinc-500">预算池</p>
<p className="font-medium text-zinc-900">{detail.budgetPool?.name ?? "待提交时自动解析"}</p>
</div>
<div>
<p className="text-zinc-500">法体</p>
<p className="font-medium text-zinc-900">{detail.legalEntity?.name ?? "-"}</p>
</div>
<div>
<p className="text-zinc-500">成本中心</p>
<p className="font-medium text-zinc-900">{detail.costCenter?.name ?? "-"}</p>
</div>
<div>
<p className="text-zinc-500">预估金额</p>
<p className="font-medium text-zinc-900">{detail.estimatedAmount}</p>
</div>
<div>
<p className="text-zinc-500">提交人</p>
<p className="font-medium text-zinc-900">{detail.submitter?.name ?? "-"}</p>
</div>
</div>
</div>
<div className="card-std p-6">
<TravelRequestForm
purpose={purpose}
setPurpose={setPurpose}
tripType={formTripType}
setTripType={setFormTripType}
startDate={startDate}
setStartDate={setStartDate}
endDate={endDate}
setEndDate={setEndDate}
legalEntityId={legalEntityId}
setLegalEntityId={setLegalEntityId}
legalEntities={legalEntitiesQuery.data?.items ?? []}
costCenterId={costCenterId}
setCostCenterId={setCostCenterId}
costCenters={selectableCostCenters}
estimatedAmount={estimatedAmount}
setEstimatedAmount={setEstimatedAmount}
segments={segments}
setSegments={setSegments}
readOnly={!isDraftEditing}
budgetPoolName={detail.budgetPool?.name ?? "待提交时自动解析"}
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">审批轨道</h3>
{detail.pendingWorkflow ? (
<div className="space-y-2 text-sm">
<div>实例:{detail.pendingWorkflow.instanceId}</div>
<div>状态:<StatusBadge status={detail.pendingWorkflow.currentStatus} /></div>
<div>当前步骤:{detail.pendingWorkflow.task?.stepName ?? detail.pendingWorkflow.currentStepId}</div>
<div>审批角色:{detail.pendingWorkflow.task?.assigneeRole ?? "-"}</div>
</div>
) : (
<p className="text-sm text-zinc-500">当前没有进行中的审批实例。</p>
)}
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">关联差旅订单</h3>
{detail.travelOrders.length ? (
<div className="space-y-3 text-sm">
{detail.travelOrders.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.externalOrderNo}</div>
<StatusBadge status={item.status} />
</div>
<div className="mt-2 text-xs text-zinc-500 space-y-1">
<div>来源:{item.source}</div>
<div>发生日期:{item.occurredAt ?? "-"}</div>
<div>金额:{item.amount ?? "-"} {item.currency}</div>
<div>报销关联:{item.linkedReimbursementId ?? "-"}</div>
<div>付款渠道:{item.paymentChannel ?? "-"} · 付款日期:{item.paymentDate ?? "-"}</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">当前暂无关联的 `travel_orders` 入站记录。</p>
)}
</div>
</div>
<TravelPaymentTrackCard travelRequestId={detail.id} />
</div>
) : null}
</section>
</div>
</>
) : canAccessReconciliationTab ? (
<TravelReconciliationTab
selectedBatchId={selectedBatchId}
onSelectBatch={selectTravelReconciliationBatch}
/>
) : (
<EmptyStateCard title="暂无可访问的差旅页签" description="当前账号缺少差旅申请与对账访问权限。" />
)}
</div>
);
}
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<SetStateAction<DraftSegment[]>>;
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 (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="travel-purpose">出差目的</Label>
<Textarea
id="travel-purpose"
value={purpose}
onChange={(event) => setPurpose(event.target.value)}
rows={3}
readOnly={readOnly}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="travel-trip-type">出差类型</Label>
<select
id="travel-trip-type"
className="select-std mt-1"
value={tripType}
onChange={(event) => setTripType(event.target.value as TravelTripType)}
disabled={readOnly}
>
<option value="domestic">国内</option>
<option value="international">国际</option>
</select>
</div>
<div>
<Label htmlFor="travel-amount">预估金额</Label>
<Input id="travel-amount" className="mt-1" value={estimatedAmount} onChange={(event) => setEstimatedAmount(event.target.value)} readOnly={readOnly} />
</div>
<div>
<Label htmlFor="travel-start-date">开始日期</Label>
<Input id="travel-start-date" className="mt-1" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} readOnly={readOnly} />
</div>
<div>
<Label htmlFor="travel-end-date">结束日期</Label>
<Input id="travel-end-date" className="mt-1" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} readOnly={readOnly} />
</div>
<div>
<Label htmlFor="travel-legal-entity">法体</Label>
<select
id="travel-legal-entity"
className="select-std mt-1"
value={legalEntityId}
onChange={(event) => {
setLegalEntityId(event.target.value);
setCostCenterId("");
}}
disabled={readOnly}
>
<option value="">请选择法体</option>
{legalEntities.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div>
<div>
<Label htmlFor="travel-cost-center">成本中心</Label>
<select
id="travel-cost-center"
className="select-std mt-1"
value={costCenterId}
onChange={(event) => setCostCenterId(event.target.value)}
disabled={readOnly}
>
<option value="">请选择成本中心</option>
{costCenters.map((item) => (
<option key={item.id} value={item.id}>{item.code} · {item.name}</option>
))}
</select>
</div>
<div className="md:col-span-2">
<Label>预算池</Label>
<div className="mt-1 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">{budgetPoolName}</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">行程段</h3>
{!readOnly ? (
<Button type="button" variant="secondary" onClick={() => setSegments((current) => [...current, emptySegment()])}>
新增行程段
</Button>
) : null}
</div>
<div className="space-y-3">
{segments.map((segment, index) => (
<div key={segment.localId} className="rounded-xl border border-zinc-200 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-zinc-900">第 {index + 1} 段</p>
{!readOnly && segments.length > 1 ? (
<Button
type="button"
variant="secondary"
onClick={() => setSegments((current) => current.filter((item) => item.localId !== segment.localId))}
>
删除
</Button>
) : null}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<Label>角色</Label>
<select
className="select-std mt-1"
value={segment.segmentRole}
disabled={readOnly}
onChange={(event) =>
setSegments((current) =>
current.map((item) => item.localId === segment.localId ? { ...item, segmentRole: event.target.value as TravelSegmentRole } : item),
)
}
>
<option value="outbound">去程</option>
<option value="return">返程</option>
<option value="connection">中转</option>
</select>
</div>
<div>
<Label>交通方式</Label>
<select
className="select-std mt-1"
value={segment.transportType}
disabled={readOnly}
onChange={(event) =>
setSegments((current) =>
current.map((item) => item.localId === segment.localId ? { ...item, transportType: event.target.value as TravelTransportType } : item),
)
}
>
<option value="flight">航班</option>
<option value="train">火车</option>
<option value="car">汽车</option>
<option value="bus">大巴</option>
<option value="other">其他</option>
</select>
</div>
<div>
<Label>出发地</Label>
<Input
className="mt-1"
value={segment.fromCity}
readOnly={readOnly}
onChange={(event) =>
setSegments((current) =>
current.map((item) => item.localId === segment.localId ? { ...item, fromCity: event.target.value } : item),
)
}
/>
</div>
<div>
<Label>目的地</Label>
<Input
className="mt-1"
value={segment.toCity}
readOnly={readOnly}
onChange={(event) =>
setSegments((current) =>
current.map((item) => item.localId === segment.localId ? { ...item, toCity: event.target.value } : item),
)
}
/>
</div>
<div>
<Label>出发时间</Label>
<Input
className="mt-1"
type="datetime-local"
value={segment.departAt}
readOnly={readOnly}
onChange={(event) =>
setSegments((current) =>
current.map((item) => item.localId === segment.localId ? { ...item, departAt: event.target.value } : item),
)
}
/>
</div>
<div>
<Label>到达时间</Label>
<Input
className="mt-1"
type="datetime-local"
value={segment.arriveAt}
readOnly={readOnly}
onChange={(event) =>
setSegments((current) =>
current.map((item) => item.localId === segment.localId ? { ...item, arriveAt: event.target.value } : item),
)
}
/>
</div>
</div>
{readOnly ? (
<p className="mt-3 text-xs text-zinc-500">{segmentRoleLabel(segment.segmentRole)} · {transportTypeLabel(segment.transportType)} · {segment.fromCity} → {segment.toCity}</p>
) : null}
</div>
))}
</div>
</div>
</div>
);
}