feat(#195): модалка «Нет встречных записей» перед шахматкой и баннер строк без ИНН
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4942120005
commit
8194f47d1a
@ -7,6 +7,7 @@ import PageTitle from '../components/PageTitle';
|
|||||||
import { TableTag } from '../components/TableTag/TableTag';
|
import { TableTag } from '../components/TableTag/TableTag';
|
||||||
import { TAG_PRESETS } from '../components/TableTag/presets';
|
import { TAG_PRESETS } from '../components/TableTag/presets';
|
||||||
import { getVersionCard } from '../services/api/consolidations';
|
import { getVersionCard } from '../services/api/consolidations';
|
||||||
|
import type { OsvSummaryOut } from '../types/api';
|
||||||
import type { ConsRussianStatus } from '../types/consolidations/common_types';
|
import type { ConsRussianStatus } from '../types/consolidations/common_types';
|
||||||
import type { VersionCard } from '../types/consolidations/version_members';
|
import type { VersionCard } from '../types/consolidations/version_members';
|
||||||
import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
|
import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
|
||||||
@ -20,14 +21,14 @@ import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
|
|||||||
function ReportContent() {
|
function ReportContent() {
|
||||||
const [card, setCard] = useState<VersionCard | null>(null);
|
const [card, setCard] = useState<VersionCard | null>(null);
|
||||||
const [statusModalOpen, setModalOpen] = useState(false);
|
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 currentUser = useCurrentUser();
|
||||||
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
|
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
|
||||||
const navigation = useNavigate();
|
const navigation = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const TAB_COMPONENTS: Record<string, ReactNode> = {
|
const TAB_COMPONENTS: Record<string, ReactNode> = {
|
||||||
osv: <OsvContent card={card} onUnmappedChange={setUnmappedTotal} />,
|
osv: <OsvContent card={card} onSummaryChange={setOsvSummary} />,
|
||||||
|
|
||||||
chess: <ChessContent card={card} />,
|
chess: <ChessContent card={card} />,
|
||||||
};
|
};
|
||||||
@ -108,7 +109,7 @@ function ReportContent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<OsvTabs unmappedTotal={unmappedTotal} />
|
<OsvTabs summary={osvSummary} versionId={version_id ?? ''} status={card?.status} />
|
||||||
</div>
|
</div>
|
||||||
{ActiveTab}
|
{ActiveTab}
|
||||||
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
|
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
isLockedBy,
|
isLockedBy,
|
||||||
isValidReason,
|
isValidReason,
|
||||||
manualTransitions,
|
manualTransitions,
|
||||||
needsUnmappedWarning,
|
|
||||||
parseInWorkOnly,
|
parseInWorkOnly,
|
||||||
rememberCounterPairsShown,
|
rememberCounterPairsShown,
|
||||||
reportTabNavigation,
|
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)', () => {
|
describe('вкладки отчёта (AURORA-1851)', () => {
|
||||||
it('смена вкладки заменяет запись истории: «Назад» ведёт со страницы отчёта, а не по вкладкам', () => {
|
it('смена вкладки заменяет запись истории: «Назад» ведёт со страницы отчёта, а не по вкладкам', () => {
|
||||||
expect(reportTabNavigation('/groups/g1/versions/v1/osv', 'osv', 'chess')).toEqual({
|
expect(reportTabNavigation('/groups/g1/versions/v1/osv', 'osv', 'chess')).toEqual({
|
||||||
|
|||||||
@ -91,9 +91,6 @@ export const endSentence = (text: string): string => (text.endsWith('.') ? text
|
|||||||
/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */
|
/** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */
|
||||||
export const MIN_PERIMETER = 2;
|
export const MIN_PERIMETER = 2;
|
||||||
|
|
||||||
/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */
|
|
||||||
export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Переход на другую вкладку отчёта (`/versions/:version_id/:tab`): сегмент вкладки заменяется, запись истории — тоже,
|
* Переход на другую вкладку отчёта (`/versions/:version_id/:tab`): сегмент вкладки заменяется, запись истории — тоже,
|
||||||
* чтобы «Назад» вёл со страницы отчёта, а не по переключениям вкладок (AURORA-1851, как переключатель документа в AURORA-1692).
|
* чтобы «Назад» вёл со страницы отчёта, а не по переключениям вкладок (AURORA-1851, как переключатель документа в AURORA-1692).
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { WarningOutlined } from '@ant-design/icons';
|
|||||||
import { Button, Modal, Segmented, Tooltip } from 'antd';
|
import { Button, Modal, Segmented, Tooltip } from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
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 = [
|
const TAB_CONFIG = [
|
||||||
{ key: 'osv', label: 'ОСВ' },
|
{ key: 'osv', label: 'ОСВ' },
|
||||||
@ -40,12 +43,23 @@ const TAB_CONFIG = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
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`. */
|
/** Ряд вкладок шагов консолидации (макет [30]); вкладка — сегмент адреса `/versions/:version_id/:tab`. */
|
||||||
export function OsvTabs({ unmappedTotal = 0 }: Props) {
|
export function OsvTabs({ summary, versionId, status }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { tab } = useParams<{ tab?: string }>();
|
const { tab } = useParams<{ tab?: string }>();
|
||||||
@ -61,7 +75,9 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
|
|||||||
const handleTabChange = (value: string | number) => {
|
const handleTabChange = (value: string | number) => {
|
||||||
const newKey = String(value);
|
const newKey = String(value);
|
||||||
if (newKey === activeKey) return;
|
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);
|
setPending(newKey);
|
||||||
} else {
|
} else {
|
||||||
go(newKey);
|
go(newKey);
|
||||||
@ -97,9 +113,17 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
|
|||||||
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
|
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
<UnmappedInnModal
|
<CounterPairsModal
|
||||||
open={pending !== null}
|
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)}
|
onStay={() => setPending(null)}
|
||||||
onGo={() => {
|
onGo={() => {
|
||||||
const next = pending;
|
const next = pending;
|
||||||
@ -111,25 +135,33 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [31-00] «Остались неуказанные ИНН»: «Остаться» — без перехода, «Перейти» — переход. */
|
/** Модалка перед шахматкой: у части участников нет встречных записей (`05-domain-rules.md` §3 п. 9). */
|
||||||
export function UnmappedInnModal({
|
export function CounterPairsModal({
|
||||||
open,
|
open,
|
||||||
|
pairs,
|
||||||
unmappedTotal,
|
unmappedTotal,
|
||||||
|
onShow,
|
||||||
onStay,
|
onStay,
|
||||||
onGo,
|
onGo,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
pairs: readonly CounterPairOut[];
|
||||||
unmappedTotal: number;
|
unmappedTotal: number;
|
||||||
|
onShow: () => void;
|
||||||
onStay: () => void;
|
onStay: () => void;
|
||||||
onGo: () => void;
|
onGo: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { headline, details } = counterPairsWarning(pairs, unmappedTotal);
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title='Остались неуказанные ИНН'
|
title='Проверьте встречные записи участников'
|
||||||
centered
|
centered
|
||||||
onCancel={onStay}
|
onCancel={onStay}
|
||||||
footer={[
|
footer={[
|
||||||
|
<Button key='show' onClick={onShow}>
|
||||||
|
Показать пары
|
||||||
|
</Button>,
|
||||||
<Button key='stay' onClick={onStay}>
|
<Button key='stay' onClick={onStay}>
|
||||||
Остаться
|
Остаться
|
||||||
</Button>,
|
</Button>,
|
||||||
@ -138,11 +170,10 @@ export function UnmappedInnModal({
|
|||||||
</Button>,
|
</Button>,
|
||||||
]}>
|
]}>
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||||
{/* треугольник с «!», как в макете [31-00] */}
|
|
||||||
<WarningOutlined style={{ color: '#dc6803', fontSize: 20, marginTop: 2 }} />
|
<WarningOutlined style={{ color: '#dc6803', fontSize: 20, marginTop: 2 }} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600 }}>В ОСВ остались неуказанные ИНН</div>
|
<div style={{ fontWeight: 600 }}>{headline}</div>
|
||||||
<div style={{ color: '#667085' }}>Не заполнено ИНН: {unmappedTotal}. Точно хотите перейти в другой раздел?</div>
|
<div style={{ color: '#667085' }}>{details}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { exportOsv } from '../../services/api/osv';
|
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 type { VersionCard } from '../../types/consolidations/version_members';
|
||||||
import { exportFilename, saveBlob } from '../../utils/download';
|
import { exportFilename, saveBlob } from '../../utils/download';
|
||||||
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
|
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
|
||||||
@ -16,11 +16,11 @@ import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumbe
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
card: VersionCard | null;
|
card: VersionCard | null;
|
||||||
/** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */
|
/** Сообщает странице отчёта сводку ОСВ (пары без встречных записей — для модалки при уходе с «ОСВ»); при размонтировании — `null`. */
|
||||||
onUnmappedChange?: (total: number) => void;
|
onSummaryChange?: (summary: OsvSummaryOut | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OsvContent({ card, onUnmappedChange }: Props) {
|
export function OsvContent({ card, onSummaryChange }: Props) {
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const { version_id } = useParams<{ version_id: string }>();
|
const { version_id } = useParams<{ version_id: string }>();
|
||||||
|
|
||||||
@ -49,10 +49,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
|
|||||||
const onlyUnmapped = osv.filters.unmapped === true;
|
const onlyUnmapped = osv.filters.unmapped === true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onUnmappedChange?.(unmapped);
|
onSummaryChange?.(s);
|
||||||
}, [unmapped, onUnmappedChange]);
|
}, [s, onSummaryChange]);
|
||||||
// вне вкладки «ОСВ» предупреждать не о чем
|
// вне вкладки «ОСВ» предупреждать не о чем
|
||||||
useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]);
|
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -104,8 +104,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) {
|
|||||||
color: '#93370d',
|
color: '#93370d',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}>
|
}}>
|
||||||
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
|
<strong style={{ color: '#7a2e0e' }}>Есть организации без ИНН и без сопоставления</strong>
|
||||||
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
|
<span>
|
||||||
|
Осталось: {unmapped}. Несопоставленные учитываются как внешние; при необходимости укажите организацию кнопкой «Указать».
|
||||||
|
</span>
|
||||||
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
|
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
|
||||||
<Button
|
<Button
|
||||||
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
|
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user