diff --git a/client/src/api/travelReconciliation.ts b/client/src/api/travelReconciliation.ts new file mode 100644 index 00000000..6ea55e0b --- /dev/null +++ b/client/src/api/travelReconciliation.ts @@ -0,0 +1,299 @@ +import { apiRequestData } from './http'; + +export type TravelReconciliationBatchStatus = 'draft' | 'reconciled'; +export type TravelReconciliationItemKind = 'order' | 'orphan_transaction'; +export type TravelReconciliationMatchStatus = + | 'matched' + | 'amount_mismatch' + | 'currency_mismatch' + | 'missing_transaction' + | 'duplicate_charge' + | 'orphan_transaction' + | 'reversed_after_payment'; +export type TravelReconciliationResolutionStatus = 'pending' | 'confirmed'; + +export type TravelReconciliationSummary = { + totalOrders: number; + totalTransactions: number; + matchedItems: number; + pendingItems: number; + anomalyItems: number; + totalOrderAmount: string; + totalSettledAmount: string; + totalReversedAmount: string; + matchRate: string; +}; + +export type TravelReconciliationBatchSummary = { + id: string; + batchNo: string; + legalEntityId: string; + costCenterId: string | null; + periodStart: string | null; + periodEnd: string | null; + status: TravelReconciliationBatchStatus; + summary: TravelReconciliationSummary; + confirmedAt: string | null; + confirmedBy: string | null; + createdAt: string | null; + updatedAt: string | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + legalEntityId: string; + } | null; + confirmedByEmployee: { + id: string; + employeeNo: string; + name: string; + } | null; +}; + +export type TravelReconciliationItem = { + id: string; + itemKind: TravelReconciliationItemKind; + travelRequestId: string | null; + travelOrderId: string | null; + cardTransactionId: string | null; + matchStatus: TravelReconciliationMatchStatus; + resolutionStatus: TravelReconciliationResolutionStatus; + orderAmount: string | null; + transactionAmount: string | null; + currency: string | null; + anomalyCode: string | null; + confirmedAt: string | null; + confirmedBy: string | null; + confirmNote: string | null; + snapshot: { + requestNo: string | null; + externalOrderNo: string | null; + externalTxnId: string | null; + travelOrderStatus: string | null; + transactionStatus: string | null; + linkedReimbursementId: string | null; + boundInvoiceCount: number; + boundInvoiceAmount: string; + matchedTransactionIds: string[]; + reasonDetail: string | null; + }; + travelRequest: { + id: string; + requestNo: string | null; + } | null; + travelOrder: { + id: string; + externalOrderNo: string; + status: string; + } | null; + cardTransaction: { + id: string; + externalTxnId: string; + status: string; + txnType: string; + } | null; + actions: { + canConfirm: boolean; + canReopen: boolean; + }; +}; + +export type TravelReconciliationBatchDetail = TravelReconciliationBatchSummary & { + items: TravelReconciliationItem[]; + permissions: { + canManagePayments: boolean; + canRetryErp: boolean; + }; +}; + +export type TravelReconciliationListResponse = { + page: number; + pageSize: number; + total: number; + items: TravelReconciliationBatchSummary[]; +}; + +export type TravelReconciliationListQuery = { + status?: TravelReconciliationBatchStatus; + legalEntityId?: string; + costCenterId?: string; + periodStart?: string; + periodEnd?: string; + page?: number; + pageSize?: number; +}; + +export type TravelReconciliationCreatePayload = { + legalEntityId: string; + costCenterId?: string; + periodStart: string; + periodEnd: string; +}; + +export type TravelReconciliationErpTrack = { + batch: { + id: string; + batchNo: string; + status: string; + periodStart: string | null; + periodEnd: string | null; + legalEntity: { + id: string; + code: string; + name: string; + } | null; + costCenter: { + id: string; + code: string; + name: string; + } | null; + }; + stage: { + summaryExport: { + eligible: boolean; + reason: string | null; + status: string; + preview: { + exportType: string; + entityType: string; + entityId: string | null; + batchNo: string | null; + occurredAt: string | null; + }; + outbox: { + id: string; + eventType: string; + status: string; + attempts: number; + lastError: string | null; + processedAt: string | null; + updatedAt: string | null; + } | null; + bindings: { + payableSummary: { + id: string; + provider: string; + entityType: string; + localId: string; + externalId: string | null; + externalCode: string | null; + lastSyncedAt: string | null; + } | null; + settlementBatch: { + id: string; + provider: string; + entityType: string; + localId: string; + externalId: string | null; + externalCode: string | null; + lastSyncedAt: string | null; + } | null; + }; + }; + }; + permissions: { + canRetry: boolean; + canSeeExternalRefs: boolean; + }; +}; + +export type TravelReconciliationFinalizeResponse = { + batchId: string; + batchNo: string; + status: TravelReconciliationBatchStatus; + erpExport: { + eventType: string; + outboxId: string | null; + duplicated: boolean; + requeued: boolean; + enqueued: boolean; + }; +}; + +export type TravelReconciliationRequeueResponse = { + outboxId: string; + status: string; + duplicated: boolean; + requeued: boolean; + eventType: string; +}; + +function toQueryString(query: Record) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === '') { + continue; + } + params.set(key, typeof value === 'boolean' ? (value ? '1' : '0') : String(value)); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ''; +} + +export async function fetchTravelReconciliationBatches(query: TravelReconciliationListQuery = {}) { + return apiRequestData( + `/api/travel/reconciliation/batches${toQueryString(query as Record)}`, + ); +} + +export async function createTravelReconciliationBatch(payload: TravelReconciliationCreatePayload) { + return apiRequestData('/api/travel/reconciliation/batches', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export async function fetchTravelReconciliationBatchDetail(batchId: string) { + return apiRequestData(`/api/travel/reconciliation/batches/${batchId}`); +} + +export async function confirmTravelReconciliationItem(itemId: string, payload: { note?: string | null } = {}) { + return apiRequestData<{ batchId: string; summary: TravelReconciliationSummary; item: TravelReconciliationItem }>( + `/api/travel/reconciliation/items/${itemId}/confirm`, + { + method: 'POST', + body: JSON.stringify(payload), + }, + ); +} + +export async function reopenTravelReconciliationItem(itemId: string) { + return apiRequestData<{ batchId: string; summary: TravelReconciliationSummary; item: TravelReconciliationItem }>( + `/api/travel/reconciliation/items/${itemId}/reopen`, + { + method: 'POST', + body: JSON.stringify({}), + }, + ); +} + +export async function finalizeTravelReconciliationBatch(batchId: string) { + return apiRequestData( + `/api/travel/reconciliation/batches/${batchId}/finalize`, + { + method: 'POST', + body: JSON.stringify({}), + }, + ); +} + +export async function fetchTravelReconciliationErpTrack(batchId: string) { + return apiRequestData( + `/api/integrations/erp/travel/reconciliation-batches/${batchId}/track`, + ); +} + +export async function requeueTravelReconciliationBatch(batchId: string) { + return apiRequestData( + `/api/integrations/erp/travel/reconciliation-batches/${batchId}/requeue`, + { + method: 'POST', + body: JSON.stringify({}), + }, + ); +} diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index e1735cc9..193f6e1e 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -34,7 +34,7 @@ export const NAV_ITEMS: NavItem[] = [ to: "/travel-requests", label: "差旅申请", icon: "flight", - anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage"], + anyOf: ["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"], }, { to: "/workflow/tasks", diff --git a/client/src/components/travel/TravelReconciliationBatchCard.tsx b/client/src/components/travel/TravelReconciliationBatchCard.tsx new file mode 100644 index 00000000..0a8a02ed --- /dev/null +++ b/client/src/components/travel/TravelReconciliationBatchCard.tsx @@ -0,0 +1,47 @@ +import { StatusBadge } from '../common/StatusBadge'; +import type { TravelReconciliationBatchSummary } from '../../api/travelReconciliation'; + +function statusLabel(status: TravelReconciliationBatchSummary['status']) { + return status === 'reconciled' ? '已对账' : '草稿'; +} + +type TravelReconciliationBatchCardProps = { + batch: TravelReconciliationBatchSummary; + selected: boolean; + onSelect: (batchId: string) => void; +}; + +export function TravelReconciliationBatchCard({ batch, selected, onSelect }: TravelReconciliationBatchCardProps) { + return ( + + ); +} diff --git a/client/src/components/travel/TravelReconciliationItemTable.tsx b/client/src/components/travel/TravelReconciliationItemTable.tsx new file mode 100644 index 00000000..d55826f4 --- /dev/null +++ b/client/src/components/travel/TravelReconciliationItemTable.tsx @@ -0,0 +1,82 @@ +import { Button } from '../ui/button'; +import { StatusBadge } from '../common/StatusBadge'; +import type { + TravelReconciliationItem, + TravelReconciliationMatchStatus, +} from '../../api/travelReconciliation'; + +function matchStatusLabel(status: TravelReconciliationMatchStatus) { + if (status === 'matched') return '精确匹配'; + if (status === 'amount_mismatch') return '金额不匹配'; + if (status === 'currency_mismatch') return '币种不匹配'; + if (status === 'missing_transaction') return '缺少交易'; + if (status === 'duplicate_charge') return '重复扣款'; + if (status === 'orphan_transaction') return '孤儿交易'; + return '支付后冲正'; +} + +function resolutionLabel(status: TravelReconciliationItem['resolutionStatus']) { + return status === 'confirmed' ? '已确认' : '待确认'; +} + +type TravelReconciliationItemTableProps = { + items: TravelReconciliationItem[]; + pendingActionItemId?: string | null; + onConfirm: (item: TravelReconciliationItem) => void; + onReopen: (item: TravelReconciliationItem) => void; +}; + +export function TravelReconciliationItemTable({ + items, + pendingActionItemId, + onConfirm, + onReopen, +}: TravelReconciliationItemTableProps) { + if (!items.length) { + return

当前批次没有对账明细。

; + } + + return ( +
+ {items.map((item) => { + const pending = pendingActionItemId === item.id; + return ( +
+
+
+
+ + {item.snapshot.externalOrderNo ?? item.snapshot.externalTxnId ?? item.id} + + + +
+
+
申请单:{item.snapshot.requestNo ?? '-'}
+
订单金额:{item.orderAmount ?? '-'} {item.currency ?? ''}
+
交易金额:{item.transactionAmount ?? '-'} {item.currency ?? ''}
+
绑票:{item.snapshot.boundInvoiceCount} 张 / {item.snapshot.boundInvoiceAmount}
+
+

{item.snapshot.reasonDetail ?? '无说明'}

+ {item.confirmNote ?

确认备注:{item.confirmNote}

: null} +
+ +
+ {item.actions.canConfirm ? ( + + ) : null} + {item.actions.canReopen ? ( + + ) : null} +
+
+
+ ); + })} +
+ ); +} diff --git a/client/src/components/travel/TravelReconciliationTab.tsx b/client/src/components/travel/TravelReconciliationTab.tsx new file mode 100644 index 00000000..7cbd681c --- /dev/null +++ b/client/src/components/travel/TravelReconciliationTab.tsx @@ -0,0 +1,519 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { mapApiErrorMessage } from '../../api/errorMessages'; +import { fetchCostCenters, fetchLegalEntities } from '../../api/masterData'; +import { + confirmTravelReconciliationItem, + createTravelReconciliationBatch, + fetchTravelReconciliationBatchDetail, + fetchTravelReconciliationBatches, + fetchTravelReconciliationErpTrack, + finalizeTravelReconciliationBatch, + reopenTravelReconciliationItem, + requeueTravelReconciliationBatch, + type TravelReconciliationBatchStatus, + type TravelReconciliationItem, +} from '../../api/travelReconciliation'; +import { isApiError } from '../../api/types'; +import { TravelReconciliationBatchCard } from './TravelReconciliationBatchCard'; +import { TravelReconciliationItemTable } from './TravelReconciliationItemTable'; +import { CardSkeleton } from '../common/CardSkeleton'; +import { EmptyStateCard } from '../common/EmptyStateCard'; +import { Pagination } from '../common/Pagination'; +import { StatusBadge } from '../common/StatusBadge'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; + +const PAGE_SIZE = 10; + +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 currentMonthRange() { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, '0'); + const first = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-01`; + const lastDate = new Date(now.getFullYear(), now.getMonth() + 1, 0); + const last = `${lastDate.getFullYear()}-${pad(lastDate.getMonth() + 1)}-${pad(lastDate.getDate())}`; + return { first, last }; +} + +function batchStatusLabel(status: TravelReconciliationBatchStatus) { + return status === 'reconciled' ? '已对账' : '草稿'; +} + +type TravelReconciliationTabProps = { + selectedBatchId: string; + onSelectBatch: (batchId: string) => void; +}; + +export function TravelReconciliationTab({ selectedBatchId, onSelectBatch }: TravelReconciliationTabProps) { + const queryClient = useQueryClient(); + const [status, setStatus] = useState(''); + const [legalEntityFilter, setLegalEntityFilter] = useState(''); + const [costCenterFilter, setCostCenterFilter] = useState(''); + const [periodStartFilter, setPeriodStartFilter] = useState(''); + const [periodEndFilter, setPeriodEndFilter] = useState(''); + const [page, setPage] = useState(1); + const [actionItemId, setActionItemId] = useState(null); + + const monthRange = useMemo(() => currentMonthRange(), []); + const [createLegalEntityId, setCreateLegalEntityId] = useState(''); + const [createCostCenterId, setCreateCostCenterId] = useState(''); + const [createPeriodStart, setCreatePeriodStart] = useState(monthRange.first); + const [createPeriodEnd, setCreatePeriodEnd] = useState(monthRange.last); + + const legalEntitiesQuery = useQuery({ + queryKey: ['travel-reconciliation', 'legal-entities'], + queryFn: () => fetchLegalEntities({ enabled: true }), + }); + + const costCentersQuery = useQuery({ + queryKey: ['travel-reconciliation', 'cost-centers'], + queryFn: () => fetchCostCenters({ enabled: true }), + }); + + const listQuery = useQuery({ + queryKey: [ + 'travel-reconciliation', + 'list', + status, + legalEntityFilter, + costCenterFilter, + periodStartFilter, + periodEndFilter, + page, + ], + queryFn: () => + fetchTravelReconciliationBatches({ + status: status || undefined, + legalEntityId: legalEntityFilter || undefined, + costCenterId: costCenterFilter || undefined, + periodStart: periodStartFilter || undefined, + periodEnd: periodEndFilter || undefined, + page, + pageSize: PAGE_SIZE, + }), + }); + + const detailQuery = useQuery({ + enabled: Boolean(selectedBatchId), + queryKey: ['travel-reconciliation', 'detail', selectedBatchId], + queryFn: () => fetchTravelReconciliationBatchDetail(selectedBatchId), + }); + + const trackQuery = useQuery({ + enabled: Boolean(selectedBatchId), + queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId], + queryFn: () => fetchTravelReconciliationErpTrack(selectedBatchId), + }); + + useEffect(() => { + if (selectedBatchId || !(listQuery.data?.items?.length)) { + return; + } + onSelectBatch(listQuery.data.items[0].id); + }, [listQuery.data?.items, onSelectBatch, selectedBatchId]); + + const createSelectableCostCenters = useMemo(() => { + return (costCentersQuery.data?.items ?? []).filter((item) => { + if (!createLegalEntityId) { + return true; + } + return item.legalEntityId === createLegalEntityId; + }); + }, [costCentersQuery.data?.items, createLegalEntityId]); + + const filterableCostCenters = useMemo(() => { + return (costCentersQuery.data?.items ?? []).filter((item) => { + if (!legalEntityFilter) { + return true; + } + return item.legalEntityId === legalEntityFilter; + }); + }, [costCentersQuery.data?.items, legalEntityFilter]); + + const createMutation = useMutation({ + mutationFn: async () => { + if (!createLegalEntityId || !createPeriodStart || !createPeriodEnd) { + throw new Error('请先填写法体与对账周期'); + } + return createTravelReconciliationBatch({ + legalEntityId: createLegalEntityId, + costCenterId: createCostCenterId || undefined, + periodStart: createPeriodStart, + periodEnd: createPeriodEnd, + }); + }, + onSuccess: async (batch) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation'] }), + queryClient.invalidateQueries({ queryKey: ['travel-requests'] }), + ]); + onSelectBatch(batch.id); + toast.success('差旅对账批次已创建'); + }, + onError: (error) => { + toast.error(resolveError(error)); + }, + }); + + const confirmMutation = useMutation({ + mutationFn: async ({ item, note }: { item: TravelReconciliationItem; note: string | null }) => { + setActionItemId(item.id); + return confirmTravelReconciliationItem(item.id, { note }); + }, + onSuccess: async (_, variables) => { + setActionItemId(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }), + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }), + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }), + ]); + toast.success(`已确认差异:${variables.item.snapshot.externalOrderNo ?? variables.item.snapshot.externalTxnId ?? variables.item.id}`); + }, + onError: (error) => { + setActionItemId(null); + toast.error(resolveError(error)); + }, + }); + + const reopenMutation = useMutation({ + mutationFn: async (item: TravelReconciliationItem) => { + setActionItemId(item.id); + return reopenTravelReconciliationItem(item.id); + }, + onSuccess: async () => { + setActionItemId(null); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'detail', selectedBatchId] }), + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'list'] }), + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }), + ]); + toast.success('差异项已重新打开'); + }, + onError: (error) => { + setActionItemId(null); + toast.error(resolveError(error)); + }, + }); + + const finalizeMutation = useMutation({ + mutationFn: async () => { + if (!selectedBatchId) { + throw new Error('请选择待收口的对账批次'); + } + return finalizeTravelReconciliationBatch(selectedBatchId); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['travel-reconciliation'] }), + queryClient.invalidateQueries({ queryKey: ['travel-requests'] }), + ]); + toast.success('差旅对账批次已收口'); + }, + onError: (error) => { + toast.error(resolveError(error)); + }, + }); + + const requeueMutation = useMutation({ + mutationFn: async () => { + if (!selectedBatchId) { + throw new Error('请选择待重试的对账批次'); + } + return requeueTravelReconciliationBatch(selectedBatchId); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['travel-reconciliation', 'erp-track', selectedBatchId] }); + toast.success('ERP 摘要桥已重新入队'); + }, + onError: (error) => { + toast.error(resolveError(error)); + }, + }); + + function handleConfirm(item: TravelReconciliationItem) { + const note = window.prompt('确认备注(可选)', item.confirmNote ?? ''); + if (note === null) { + return; + } + confirmMutation.mutate({ item, note: note.trim() || null }); + } + + const detail = detailQuery.data ?? null; + const track = trackQuery.data ?? null; + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); + + return ( +
+
+ {detail?.permissions.canManagePayments ? ( +
+
+

新建对账批次

+

创建批次后会固化快照,只做差异发现与人工确认。

+
+
+
+ + +
+
+ + +
+
+ + setCreatePeriodStart(event.target.value)} /> +
+
+ + setCreatePeriodEnd(event.target.value)} /> +
+
+
+ +
+
+ ) : null} + +
+
+

对账批次

+

筛选并打开已有批次,支持 deep link 到 `batchId`。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + { setPeriodStartFilter(event.target.value); setPage(1); }} /> +
+
+ + { setPeriodEndFilter(event.target.value); setPage(1); }} /> +
+
+ + {listQuery.isLoading ? : null} + {listQuery.isError ?

{resolveError(listQuery.error)}

: null} + + {!listQuery.isLoading && !listQuery.isError ? ( +
+ {(listQuery.data?.items ?? []).length ? ( + (listQuery.data?.items ?? []).map((batch) => ( + + )) + ) : ( + + )} +
+ ) : null} + + +
+
+ +
+ {selectedBatchId && detailQuery.isLoading ? : null} + {selectedBatchId && detailQuery.isError ? ( +
{resolveError(detailQuery.error)}
+ ) : null} + + {!selectedBatchId ? ( + + ) : null} + + {detail ? ( + <> +
+
+
+
+

{detail.batchNo}

+ +
+

+ {detail.legalEntity?.name ?? '-'} + {detail.costCenter?.name ? ` · ${detail.costCenter.name}` : ''} + {' · '} + {detail.periodStart ?? '-'} → {detail.periodEnd ?? '-'} +

+
+
+ {detail.permissions.canManagePayments ? ( + + ) : null} + {track?.permissions.canRetry ? ( + + ) : null} +
+
+ +
+
+

匹配率

+

{detail.summary.matchRate}%

+
+
+

异常项 / 待确认

+

{detail.summary.anomalyItems} / {detail.summary.pendingItems}

+
+
+

订单金额

+

{detail.summary.totalOrderAmount}

+
+
+

交易 / 冲正

+

{detail.summary.totalSettledAmount} / {detail.summary.totalReversedAmount}

+
+
+
+ +
+
+

ERP 摘要桥

+

批次级 `erp.travel.export` 轨道,不会回改订单、申请或预算原值。

+
+ + {trackQuery.isLoading ? : null} + {trackQuery.isError ? ( +

+ ERP 轨道加载失败:{resolveError(trackQuery.error)} +

+ ) : null} + + {track ? ( +
+
+

阶段状态

+
+
+
+

Outbox

+

{track.stage.summaryExport.outbox?.id ?? '未入队'}

+
+
+

应付摘要

+

{track.stage.summaryExport.bindings.payableSummary?.externalCode ?? '未生成'}

+
+
+

结算批次

+

{track.stage.summaryExport.bindings.settlementBatch?.externalCode ?? '未生成'}

+
+
+ ) : null} +
+ +
+
+

异常与明细

+

`matched` 项自动确认,其余 anomaly 需人工确认后才能 finalize。

+
+ reopenMutation.mutate(item)} + /> +
+ + ) : null} +
+
+ ); +} diff --git a/client/src/pages/TravelRequestsPage.tsx b/client/src/pages/TravelRequestsPage.tsx index b85c1ab5..26328bfe 100644 --- a/client/src/pages/TravelRequestsPage.tsx +++ b/client/src/pages/TravelRequestsPage.tsx @@ -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(""); const [mine, setMine] = useState(true); @@ -184,22 +198,36 @@ export function TravelRequestsPage() { const [segments, setSegments] = useState([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 (
selectTravelRequest("")}>新建差旅申请 ) : undefined } /> - {formError ? ( -

{formError}

- ) : null} +
+ {canAccessRequestsTab ? ( + + ) : null} + {canAccessReconciliationTab ? ( + + ) : null} +
-
+ {activeTab === "requests" ? ( + <> + {formError ? ( +

{formError}

+ ) : null} + +
@@ -739,7 +808,16 @@ export function TravelRequestsPage() {
) : null}
-
+
+ + ) : canAccessReconciliationTab ? ( + + ) : ( + + )}
); } diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index b30daec7..5ed42893 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -116,7 +116,7 @@ export function AppRouter() { + }