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 { 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)} />}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' }}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user