225 lines
6.7 KiB
TypeScript
225 lines
6.7 KiB
TypeScript
import { WarningOutlined } from '@ant-design/icons';
|
||
import { Button, Segmented, Tooltip } from 'antd';
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||
import { AppModal } from '../../components/AppModal/AppModal';
|
||
import { getOsvSummary } from '../../services/api/osv';
|
||
import type { CounterPairOut, OsvSummaryOut } from '../../types/api';
|
||
import type { ConsStatus } from '../../types/consolidations/common_types';
|
||
import {
|
||
counterPairsWarning,
|
||
decideOsvLeave,
|
||
rememberCounterPairsShown,
|
||
reportTabNavigation,
|
||
resolveLeaveAfterFetch,
|
||
} from '../../utils/consolidations';
|
||
import { pairsViewSearch } from './counterPairs';
|
||
|
||
const TAB_CONFIG = [
|
||
{ key: 'osv', label: 'ОСВ' },
|
||
{ key: 'chess', label: 'Шахматка' },
|
||
{
|
||
key: 'correction',
|
||
label: 'Таблица корректировки',
|
||
disabled: true,
|
||
tooltip: 'Будет в следующих срезах',
|
||
},
|
||
{
|
||
key: 'goodwill',
|
||
label: 'Гудвилл',
|
||
},
|
||
{
|
||
key: 'kfo',
|
||
label: 'КФО',
|
||
disabled: true,
|
||
tooltip: 'Будет в следующих срезах',
|
||
},
|
||
{
|
||
key: 'kfo_common',
|
||
label: 'КФО Общий',
|
||
disabled: true,
|
||
tooltip: 'Будет в следующих срезах',
|
||
},
|
||
{
|
||
key: 'kfo_confirm',
|
||
label: 'Подтверждение КФО клиента',
|
||
disabled: true,
|
||
tooltip: 'Будет в следующих срезах',
|
||
},
|
||
];
|
||
|
||
type Props = {
|
||
/** Сводка ОСВ: есть пары без встречных записей — переход «ОСВ» → «Шахматка» через модалку. */
|
||
summary: OsvSummaryOut | null;
|
||
versionId: string;
|
||
status: ConsStatus | undefined;
|
||
};
|
||
|
||
/** `sessionStorage` может бросать (приватный режим) — тогда `null`. */
|
||
const safeSessionStorage = (): Storage | null => {
|
||
try {
|
||
return window.sessionStorage;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/** Ряд вкладок шагов консолидации (макет [30]); вкладка — сегмент адреса `/versions/:version_id/:tab`. */
|
||
export function OsvTabs({ summary, versionId, status }: Props) {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const { tab } = useParams<{ tab?: string }>();
|
||
// переход, ожидающий решения в модалке; сводка — та, по которой модалка показана (только на время модалки)
|
||
const [pending, setPending] = useState<{ key: string; summary: OsvSummaryOut | null } | null>(null);
|
||
|
||
const activeKey = tab || 'osv';
|
||
|
||
const go = (newKey: string) => {
|
||
const { to, options } = reportTabNavigation(location.pathname, activeKey, newKey);
|
||
navigate(to, options);
|
||
};
|
||
|
||
const [checking, setChecking] = useState(false);
|
||
|
||
// актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется
|
||
const liveRef = useRef({ versionId, mounted: true });
|
||
liveRef.current.versionId = versionId;
|
||
useEffect(() => {
|
||
liveRef.current.mounted = true;
|
||
return () => {
|
||
liveRef.current.mounted = false;
|
||
};
|
||
}, []);
|
||
|
||
const handleTabChange = async (value: string | number) => {
|
||
const newKey = String(value);
|
||
if (newKey === activeKey || checking) return;
|
||
if (activeKey !== 'osv') {
|
||
go(newKey);
|
||
return;
|
||
}
|
||
const storage = safeSessionStorage();
|
||
let found: OsvSummaryOut | null = summary;
|
||
let decision: 'warn' | 'go' | 'perform' = decideOsvLeave({ summary: found, versionId, status, storage });
|
||
if (decision === 'perform') {
|
||
setChecking(true);
|
||
const requestedFor = versionId;
|
||
let result: OsvSummaryOut | null = null;
|
||
try {
|
||
result = await getOsvSummary(requestedFor);
|
||
} catch {
|
||
// не удалось проверить — не держим пользователя на вкладке
|
||
}
|
||
if (!liveRef.current.mounted) return;
|
||
setChecking(false);
|
||
if (liveRef.current.versionId !== requestedFor) return;
|
||
found = result;
|
||
decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage });
|
||
}
|
||
if (decision === 'warn' && found) {
|
||
rememberCounterPairsShown(versionId, found.counter_pairs, storage);
|
||
setPending({ key: newKey, summary: found });
|
||
} else {
|
||
go(newKey);
|
||
}
|
||
};
|
||
|
||
const options = TAB_CONFIG.map((tabItem) => {
|
||
const isActive = tabItem.key === activeKey;
|
||
|
||
const label = (
|
||
<span
|
||
key={tabItem.key}
|
||
style={{
|
||
color: isActive ? '#2a8c3a' : undefined,
|
||
fontWeight: isActive ? 600 : 400,
|
||
}}>
|
||
{tabItem.label}
|
||
</span>
|
||
);
|
||
|
||
return {
|
||
value: tabItem.key,
|
||
disabled: tabItem.disabled,
|
||
label: tabItem.tooltip ? <Tooltip title={tabItem.tooltip}>{label}</Tooltip> : label,
|
||
};
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<Segmented
|
||
className='customSegment'
|
||
value={activeKey}
|
||
onChange={handleTabChange}
|
||
disabled={checking}
|
||
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', background: '#f1f3f4' }}
|
||
options={options}
|
||
/>
|
||
<CounterPairsModal
|
||
open={pending !== null}
|
||
pairs={pending?.summary?.counter_pairs ?? []}
|
||
unmappedTotal={pending?.summary?.unmapped_total ?? 0}
|
||
onShow={() => {
|
||
setPending(null);
|
||
navigate(
|
||
{ pathname: location.pathname, search: pairsViewSearch({ view: 'pairs', holder: null, missing: null }) },
|
||
{ replace: true },
|
||
);
|
||
}}
|
||
onStay={() => setPending(null)}
|
||
onGo={() => {
|
||
const next = pending?.key;
|
||
setPending(null);
|
||
if (next) go(next);
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Модалка перед шахматкой: у части участников нет встречных записей (`05-domain-rules.md` §3 п. 9). */
|
||
export function CounterPairsModal({
|
||
open,
|
||
pairs,
|
||
unmappedTotal,
|
||
onShow,
|
||
onStay,
|
||
onGo,
|
||
}: {
|
||
open: boolean;
|
||
pairs: readonly CounterPairOut[];
|
||
unmappedTotal: number;
|
||
onShow: () => void;
|
||
onStay: () => void;
|
||
onGo: () => void;
|
||
}) {
|
||
const { headline, details } = counterPairsWarning(pairs, unmappedTotal);
|
||
return (
|
||
<AppModal
|
||
open={open}
|
||
title='Проверьте встречные записи участников'
|
||
onCancel={onStay}
|
||
footer={[
|
||
<Button key='show' onClick={onShow}>
|
||
Показать пары
|
||
</Button>,
|
||
<Button key='stay' onClick={onStay}>
|
||
Остаться
|
||
</Button>,
|
||
<Button key='go' type='primary' onClick={onGo}>
|
||
Перейти
|
||
</Button>,
|
||
]}>
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||
<WarningOutlined
|
||
style={{ background: '#fff4e5', color: '#dc6803', fontSize: 20, marginTop: 2, padding: 10, borderRadius: '50%' }}
|
||
/>
|
||
<div>
|
||
<div style={{ fontWeight: 600 }}>{headline}</div>
|
||
<div style={{ color: '#667085' }}>{details}</div>
|
||
</div>
|
||
</div>
|
||
</AppModal>
|
||
);
|
||
}
|