feat(#195): модалка «Нет встречных записей» перед шахматкой и баннер строк без ИНН

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 18:56:51 +04:00
parent 4942120005
commit 8194f47d1a
5 changed files with 60 additions and 38 deletions

View File

@ -7,6 +7,7 @@ import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import type { OsvSummaryOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
@ -20,14 +21,14 @@ import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
function ReportContent() {
const [card, setCard] = useState<VersionCard | null>(null);
const [statusModalOpen, setModalOpen] = useState(false);
// unmapped_total сводки ОСВ — для [31-00] при уходе с вкладки «ОСВ»
const [unmappedTotal, setUnmappedTotal] = useState(0);
// сводка ОСВ (пары без встречных записей) — для модалки при уходе с вкладки «ОСВ»
const [osvSummary, setOsvSummary] = useState<OsvSummaryOut | null>(null);
const currentUser = useCurrentUser();
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
const navigation = useNavigate();
const { pathname } = useLocation();
const TAB_COMPONENTS: Record<string, ReactNode> = {
osv: <OsvContent card={card} onUnmappedChange={setUnmappedTotal} />,
osv: <OsvContent card={card} onSummaryChange={setOsvSummary} />,
chess: <ChessContent card={card} />,
};
@ -108,7 +109,7 @@ function ReportContent() {
)
}
/>
<OsvTabs unmappedTotal={unmappedTotal} />
<OsvTabs summary={osvSummary} versionId={version_id ?? ''} status={card?.status} />
</div>
{ActiveTab}
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}

View File

@ -15,7 +15,6 @@ import {
isLockedBy,
isValidReason,
manualTransitions,
needsUnmappedWarning,
parseInWorkOnly,
rememberCounterPairsShown,
reportTabNavigation,
@ -133,14 +132,6 @@ describe('endSentence', () => {
});
});
describe('модалка [31-00]', () => {
it('только при несопоставленных строках', () => {
expect(needsUnmappedWarning(2)).toBe(true);
expect(needsUnmappedWarning(0)).toBe(false);
expect(needsUnmappedWarning(undefined)).toBe(false);
});
});
describe('вкладки отчёта (AURORA-1851)', () => {
it('смена вкладки заменяет запись истории: «Назад» ведёт со страницы отчёта, а не по вкладкам', () => {
expect(reportTabNavigation('/groups/g1/versions/v1/osv', 'osv', 'chess')).toEqual({

View File

@ -91,9 +91,6 @@ export const endSentence = (text: string): string => (text.endsWith('.') ? text
/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */
export const MIN_PERIMETER = 2;
/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */
export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0;
/**
* Переход на другую вкладку отчёта (`/versions/:version_id/:tab`): сегмент вкладки заменяется, запись истории — тоже,
* чтобы «Назад» вёл со страницы отчёта, а не по переключениям вкладок (AURORA-1851, как переключатель документа в AURORA-1692).

View File

@ -2,7 +2,10 @@ import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd';
import { useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { needsUnmappedWarning, reportTabNavigation } from '../../utils/consolidations';
import type { CounterPairOut, OsvSummaryOut } from '../../types/api';
import type { ConsStatus } from '../../types/consolidations/common_types';
import { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations';
import { pairsViewSearch } from './counterPairs';
const TAB_CONFIG = [
{ key: 'osv', label: 'ОСВ' },
@ -40,12 +43,23 @@ const TAB_CONFIG = [
];
type Props = {
/** `unmapped_total` сводки ОСВ: больше нуля — уход с «ОСВ» через модалку [31-00]. */
unmappedTotal?: number;
/** Сводка ОСВ: есть пары без встречных записей — переход «ОСВ» → «Шахматка» через модалку. */
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({ unmappedTotal = 0 }: Props) {
export function OsvTabs({ summary, versionId, status }: Props) {
const navigate = useNavigate();
const location = useLocation();
const { tab } = useParams<{ tab?: string }>();
@ -61,7 +75,9 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
const handleTabChange = (value: string | number) => {
const newKey = String(value);
if (newKey === activeKey) return;
if (activeKey === 'osv' && needsUnmappedWarning(unmappedTotal)) {
const pairs = summary?.counter_pairs ?? [];
if (activeKey === 'osv' && newKey === 'chess' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) {
rememberCounterPairsShown(versionId, pairs, safeSessionStorage());
setPending(newKey);
} else {
go(newKey);
@ -97,9 +113,17 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={options}
/>
<UnmappedInnModal
<CounterPairsModal
open={pending !== null}
unmappedTotal={unmappedTotal}
pairs={summary?.counter_pairs ?? []}
unmappedTotal={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;
@ -111,25 +135,33 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
);
}
/** [31-00] «Остались неуказанные ИНН»: «Остаться» — без перехода, «Перейти» — переход. */
export function UnmappedInnModal({
/** Модалка перед шахматкой: у части участников нет встречных записей (`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 (
<Modal
open={open}
title='Остались неуказанные ИНН'
title='Проверьте встречные записи участников'
centered
onCancel={onStay}
footer={[
<Button key='show' onClick={onShow}>
Показать пары
</Button>,
<Button key='stay' onClick={onStay}>
Остаться
</Button>,
@ -138,11 +170,10 @@ export function UnmappedInnModal({
</Button>,
]}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{/* треугольник с «!», как в макете [31-00] */}
<WarningOutlined style={{ color: '#dc6803', fontSize: 20, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 600 }}>В ОСВ остались неуказанные ИНН</div>
<div style={{ color: '#667085' }}>Не заполнено ИНН: {unmappedTotal}. Точно хотите перейти в другой раздел?</div>
<div style={{ fontWeight: 600 }}>{headline}</div>
<div style={{ color: '#667085' }}>{details}</div>
</div>
</div>
</Modal>

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut } from '../../types/api';
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
@ -16,11 +16,11 @@ import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumbe
type Props = {
card: VersionCard | null;
/** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */
onUnmappedChange?: (total: number) => void;
/** Сообщает странице отчёта сводку ОСВ (пары без встречных записей — для модалки при уходе с «ОСВ»); при размонтировании — `null`. */
onSummaryChange?: (summary: OsvSummaryOut | null) => void;
};
export function OsvContent({ card, onUnmappedChange }: Props) {
export function OsvContent({ card, onSummaryChange }: Props) {
const [exporting, setExporting] = useState(false);
const { version_id } = useParams<{ version_id: string }>();
@ -49,10 +49,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
const onlyUnmapped = osv.filters.unmapped === true;
useEffect(() => {
onUnmappedChange?.(unmapped);
}, [unmapped, onUnmappedChange]);
onSummaryChange?.(s);
}, [s, onSummaryChange]);
// вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]);
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
return (
<>
@ -104,8 +104,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
color: '#93370d',
fontSize: 13,
}}>
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
<strong style={{ color: '#7a2e0e' }}>Есть организации без ИНН и без сопоставления</strong>
<span>
Осталось: {unmapped}. Несопоставленные учитываются как внешние; при необходимости укажите организацию кнопкой «Указать».
</span>
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
<Button
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}