feat(P20T3-4): 对账Tab与批次看板前端交互

这个提交包含在:
2026-03-11 16:55:04 +08:00
父节点 fc0295249e
当前提交 f3fb3ded90
共修改 7 个文件,包含 1038 行新增和 13 行删除
+89 -11
查看文件
@@ -29,6 +29,7 @@ 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";
@@ -163,7 +164,20 @@ export function TravelRequestsPage() {
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);
@@ -184,22 +198,36 @@ export function TravelRequestsPage() {
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({
@@ -214,7 +242,7 @@ export function TravelRequestsPage() {
});
const detailQuery = useQuery({
enabled: Boolean(selectedId),
enabled: canAccessRequestsTab && activeTab === "requests" && Boolean(selectedId),
queryKey: ["travel-requests", "detail", selectedId],
queryFn: () => fetchTravelRequestDetail(selectedId),
});
@@ -265,7 +293,7 @@ export function TravelRequestsPage() {
}, [detailQuery.data, selectedId]);
useEffect(() => {
if (selectedId || canCreate) {
if (activeTab !== "requests" || selectedId || canCreate) {
return;
}
const nextId = listQuery.data?.items?.[0]?.id ?? "";
@@ -275,14 +303,21 @@ export function TravelRequestsPage() {
const next = new URLSearchParams(searchParams);
next.set("id", nextId);
setSearchParams(next);
}, [canCreate, listQuery.data?.items, selectedId, setSearchParams]);
}, [activeTab, canCreate, listQuery.data?.items, searchParams, selectedId, setSearchParams]);
const detail = detailQuery.data ?? null;
const isDraftEditing = Boolean(detail && detail.status === "draft" && detail.permissions.canEditDraft);
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 {
@@ -291,6 +326,17 @@ export function TravelRequestsPage() {
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("当前无可保存的差旅申请");
@@ -392,20 +438,43 @@ export function TravelRequestsPage() {
return (
<div className="space-y-6">
<PageHeader
title="差旅申请"
subtitle="差旅主单、行程段、预算锁定与审批待办统一收口。"
title="差旅管理"
subtitle="差旅申请、对公支付轨道与批次对账在同一路径统一收口。"
actions={
canCreate ? (
activeTab === "requests" && canCreate ? (
<Button type="button" onClick={() => selectTravelRequest("")}>新建差旅申请</Button>
) : undefined
}
/>
{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="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>
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
{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>
@@ -739,7 +808,16 @@ export function TravelRequestsPage() {
</div>
) : null}
</section>
</div>
</div>
</>
) : canAccessReconciliationTab ? (
<TravelReconciliationTab
selectedBatchId={selectedBatchId}
onSelectBatch={selectTravelReconciliationBatch}
/>
) : (
<EmptyStateCard title="暂无可访问的差旅页签" description="当前账号缺少差旅申请与对账访问权限。" />
)}
</div>
);
}