225 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}