feat(P20T3-4): 对账Tab与批次看板前端交互
这个提交包含在:
@@ -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<string, string | number | boolean | undefined>) {
|
||||
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<TravelReconciliationListResponse>(
|
||||
`/api/travel/reconciliation/batches${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTravelReconciliationBatch(payload: TravelReconciliationCreatePayload) {
|
||||
return apiRequestData<TravelReconciliationBatchDetail>('/api/travel/reconciliation/batches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchTravelReconciliationBatchDetail(batchId: string) {
|
||||
return apiRequestData<TravelReconciliationBatchDetail>(`/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<TravelReconciliationFinalizeResponse>(
|
||||
`/api/travel/reconciliation/batches/${batchId}/finalize`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchTravelReconciliationErpTrack(batchId: string) {
|
||||
return apiRequestData<TravelReconciliationErpTrack>(
|
||||
`/api/integrations/erp/travel/reconciliation-batches/${batchId}/track`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function requeueTravelReconciliationBatch(batchId: string) {
|
||||
return apiRequestData<TravelReconciliationRequeueResponse>(
|
||||
`/api/integrations/erp/travel/reconciliation-batches/${batchId}/requeue`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(batch.id)}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-left transition ${
|
||||
selected ? 'border-primary bg-primary-50' : 'border-zinc-200 hover:border-zinc-300'
|
||||
}`}
|
||||
data-testid={`travel-reconciliation-batch-${batch.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-900">{batch.batchNo}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{batch.periodStart ?? '-'} → {batch.periodEnd ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={batch.status} label={statusLabel(batch.status)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-zinc-500">
|
||||
<div>匹配率:{batch.summary.matchRate}%</div>
|
||||
<div>异常项:{batch.summary.anomalyItems}</div>
|
||||
<div>待确认:{batch.summary.pendingItems}</div>
|
||||
<div>订单金额:{batch.summary.totalOrderAmount}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
{batch.legalEntity?.name ?? '-'}
|
||||
{batch.costCenter?.name ? ` · ${batch.costCenter.name}` : ''}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 <p className="text-sm text-zinc-500">当前批次没有对账明细。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const pending = pendingActionItemId === item.id;
|
||||
return (
|
||||
<div key={item.id} className="rounded-xl border border-zinc-200 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-zinc-900">
|
||||
{item.snapshot.externalOrderNo ?? item.snapshot.externalTxnId ?? item.id}
|
||||
</span>
|
||||
<StatusBadge status={item.matchStatus} label={matchStatusLabel(item.matchStatus)} />
|
||||
<StatusBadge status={item.resolutionStatus} label={resolutionLabel(item.resolutionStatus)} />
|
||||
</div>
|
||||
<div className="grid gap-2 text-xs text-zinc-500 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>申请单:{item.snapshot.requestNo ?? '-'}</div>
|
||||
<div>订单金额:{item.orderAmount ?? '-'} {item.currency ?? ''}</div>
|
||||
<div>交易金额:{item.transactionAmount ?? '-'} {item.currency ?? ''}</div>
|
||||
<div>绑票:{item.snapshot.boundInvoiceCount} 张 / {item.snapshot.boundInvoiceAmount}</div>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600">{item.snapshot.reasonDetail ?? '无说明'}</p>
|
||||
{item.confirmNote ? <p className="text-xs text-zinc-500">确认备注:{item.confirmNote}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{item.actions.canConfirm ? (
|
||||
<Button type="button" variant="secondary" disabled={pending} onClick={() => onConfirm(item)}>
|
||||
{pending ? '处理中...' : '确认差异'}
|
||||
</Button>
|
||||
) : null}
|
||||
{item.actions.canReopen ? (
|
||||
<Button type="button" variant="secondary" disabled={pending} onClick={() => onReopen(item)}>
|
||||
{pending ? '处理中...' : '重新打开'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<TravelReconciliationBatchStatus | ''>('');
|
||||
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<string | null>(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 (
|
||||
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<section className="space-y-6">
|
||||
{detail?.permissions.canManagePayments ? (
|
||||
<div className="card-std space-y-4 p-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建对账批次</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">创建批次后会固化快照,只做差异发现与人工确认。</p>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-create-legal-entity">法体</Label>
|
||||
<select
|
||||
id="travel-recon-create-legal-entity"
|
||||
className="select-std mt-1"
|
||||
value={createLegalEntityId}
|
||||
onChange={(event) => {
|
||||
setCreateLegalEntityId(event.target.value);
|
||||
setCreateCostCenterId('');
|
||||
}}
|
||||
>
|
||||
<option value="">请选择法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-create-cost-center">成本中心</Label>
|
||||
<select
|
||||
id="travel-recon-create-cost-center"
|
||||
className="select-std mt-1"
|
||||
value={createCostCenterId}
|
||||
onChange={(event) => setCreateCostCenterId(event.target.value)}
|
||||
>
|
||||
<option value="">全部成本中心</option>
|
||||
{createSelectableCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.code} · {item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-create-period-start">周期开始</Label>
|
||||
<Input id="travel-recon-create-period-start" className="mt-1" type="date" value={createPeriodStart} onChange={(event) => setCreatePeriodStart(event.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-create-period-end">周期结束</Label>
|
||||
<Input id="travel-recon-create-period-end" className="mt-1" type="date" value={createPeriodEnd} onChange={(event) => setCreatePeriodEnd(event.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? '创建中...' : '创建批次'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std space-y-4 p-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">对账批次</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">筛选并打开已有批次,支持 deep link 到 `batchId`。</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-filter-status">状态</Label>
|
||||
<select
|
||||
id="travel-recon-filter-status"
|
||||
className="select-std mt-1"
|
||||
value={status}
|
||||
onChange={(event) => {
|
||||
setStatus(event.target.value as TravelReconciliationBatchStatus | '');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="reconciled">已对账</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-filter-legal-entity">法体</Label>
|
||||
<select
|
||||
id="travel-recon-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-recon-filter-cost-center">成本中心</Label>
|
||||
<select
|
||||
id="travel-recon-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.code} · {item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-filter-period-start">周期开始</Label>
|
||||
<Input id="travel-recon-filter-period-start" className="mt-1" type="date" value={periodStartFilter} onChange={(event) => { setPeriodStartFilter(event.target.value); setPage(1); }} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="travel-recon-filter-period-end">周期结束</Label>
|
||||
<Input id="travel-recon-filter-period-end" className="mt-1" type="date" value={periodEndFilter} onChange={(event) => { setPeriodEndFilter(event.target.value); setPage(1); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <CardSkeleton lines={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((batch) => (
|
||||
<TravelReconciliationBatchCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
selected={batch.id === selectedBatchId}
|
||||
onSelect={onSelectBatch}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<EmptyStateCard title="暂无对账批次" description="当前筛选条件下没有可查看的差旅对账批次。" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} testIdPrefix="travel-reconciliation" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-6">
|
||||
{selectedBatchId && detailQuery.isLoading ? <CardSkeleton lines={10} /> : null}
|
||||
{selectedBatchId && detailQuery.isError ? (
|
||||
<div className="card-std p-6 text-sm text-red-600">{resolveError(detailQuery.error)}</div>
|
||||
) : null}
|
||||
|
||||
{!selectedBatchId ? (
|
||||
<EmptyStateCard title="请选择对账批次" description="从左侧选择一个批次查看 KPI、异常项与 ERP 摘要桥轨道。" />
|
||||
) : null}
|
||||
|
||||
{detail ? (
|
||||
<>
|
||||
<div className="card-std space-y-5 p-6">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{detail.batchNo}</h3>
|
||||
<StatusBadge status={detail.status} label={batchStatusLabel(detail.status)} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{detail.legalEntity?.name ?? '-'}
|
||||
{detail.costCenter?.name ? ` · ${detail.costCenter.name}` : ''}
|
||||
{' · '}
|
||||
{detail.periodStart ?? '-'} → {detail.periodEnd ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{detail.permissions.canManagePayments ? (
|
||||
<Button type="button" onClick={() => finalizeMutation.mutate()} disabled={finalizeMutation.isPending || detail.status === 'reconciled'}>
|
||||
{finalizeMutation.isPending ? '收口中...' : detail.status === 'reconciled' ? '已收口' : 'Finalize'}
|
||||
</Button>
|
||||
) : null}
|
||||
{track?.permissions.canRetry ? (
|
||||
<Button type="button" variant="secondary" onClick={() => requeueMutation.mutate()} disabled={requeueMutation.isPending}>
|
||||
{requeueMutation.isPending ? '重试中...' : 'Requeue ERP'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
||||
<div className="rounded-xl border border-zinc-200 p-4">
|
||||
<p className="text-zinc-500">匹配率</p>
|
||||
<p className="mt-2 text-xl font-semibold text-zinc-900">{detail.summary.matchRate}%</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 p-4">
|
||||
<p className="text-zinc-500">异常项 / 待确认</p>
|
||||
<p className="mt-2 text-xl font-semibold text-zinc-900">{detail.summary.anomalyItems} / {detail.summary.pendingItems}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 p-4">
|
||||
<p className="text-zinc-500">订单金额</p>
|
||||
<p className="mt-2 text-xl font-semibold text-zinc-900">{detail.summary.totalOrderAmount}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 p-4">
|
||||
<p className="text-zinc-500">交易 / 冲正</p>
|
||||
<p className="mt-2 text-xl font-semibold text-zinc-900">{detail.summary.totalSettledAmount} / {detail.summary.totalReversedAmount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">ERP 摘要桥</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">批次级 `erp.travel.export` 轨道,不会回改订单、申请或预算原值。</p>
|
||||
</div>
|
||||
|
||||
{trackQuery.isLoading ? <CardSkeleton lines={4} /> : null}
|
||||
{trackQuery.isError ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||
ERP 轨道加载失败:{resolveError(trackQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{track ? (
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-zinc-500">阶段状态</p>
|
||||
<div className="mt-1"><StatusBadge status={track.stage.summaryExport.status} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">Outbox</p>
|
||||
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.outbox?.id ?? '未入队'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">应付摘要</p>
|
||||
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.bindings.payableSummary?.externalCode ?? '未生成'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">结算批次</p>
|
||||
<p className="mt-1 font-medium text-zinc-900">{track.stage.summaryExport.bindings.settlementBatch?.externalCode ?? '未生成'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">异常与明细</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">`matched` 项自动确认,其余 anomaly 需人工确认后才能 finalize。</p>
|
||||
</div>
|
||||
<TravelReconciliationItemTable
|
||||
items={detail.items}
|
||||
pendingActionItemId={actionItemId}
|
||||
onConfirm={handleConfirm}
|
||||
onReopen={(item) => reopenMutation.mutate(item)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export function AppRouter() {
|
||||
<Route
|
||||
path="travel-requests"
|
||||
element={
|
||||
<RequirePermission anyOf={["travel.create", "travel.view_own", "travel.view_all", "travel.manage"]}>
|
||||
<RequirePermission anyOf={["travel.create", "travel.view_own", "travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"]}>
|
||||
<TravelRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户