feat(P20T3-4): 对账Tab与批次看板前端交互
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户