关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
525 行
22 KiB
TypeScript
525 行
22 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { mapApiErrorMessage } from '../../api/errorMessages';
|
|
import { useAuth } from '../../auth/AuthContext';
|
|
import { hasPermission } from '../../auth/permissions';
|
|
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 { permissions } = useAuth();
|
|
const canCreateBatch =
|
|
hasPermission(permissions, 'travel.payment.manage') || hasPermission(permissions, 'integration.manage');
|
|
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">
|
|
{canCreateBatch ? (
|
|
<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>
|
|
);
|
|
}
|