feat(#195): переход с вкладки ОСВ, режим пар без лишних запросов, пара в адресе при «Свод»
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b9a55873e5
commit
8054ff205d
@ -11,6 +11,7 @@ import {
|
||||
consStatusDescription,
|
||||
counterPairsKey,
|
||||
counterPairsWarning,
|
||||
decideOsvLeave,
|
||||
endSentence,
|
||||
isLockedBy,
|
||||
isValidReason,
|
||||
@ -222,3 +223,25 @@ describe('counterPairsWarning', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideOsvLeave (T4.2)', () => {
|
||||
const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut];
|
||||
const noStorage = { getItem: () => null };
|
||||
const base = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: noStorage };
|
||||
it('сводки нет и версия открыта — запросить', () => {
|
||||
expect(decideOsvLeave({ ...base, summary: null })).toBe('perform');
|
||||
});
|
||||
it('версия закрыта — переход без запроса', () => {
|
||||
expect(decideOsvLeave({ ...base, status: 'VERIFIED', summary: null })).toBe('go');
|
||||
});
|
||||
it('сводка есть, пары есть и не показывали — предупредить', () => {
|
||||
expect(decideOsvLeave({ ...base, summary: { counter_pairs: pairs } })).toBe('warn');
|
||||
});
|
||||
it('сводка есть, пар нет — перейти', () => {
|
||||
expect(decideOsvLeave({ ...base, summary: { counter_pairs: [] } })).toBe('go');
|
||||
});
|
||||
it('список уже показывали — перейти', () => {
|
||||
const shown = { getItem: () => 'a>b' };
|
||||
expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
|
||||
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
|
||||
import type { CounterPairOut } from '../types/api';
|
||||
import type { CounterPairOut, OsvSummaryOut } from '../types/api';
|
||||
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
|
||||
import { pluralRu } from './labels';
|
||||
|
||||
@ -154,3 +154,19 @@ export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTo
|
||||
details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base,
|
||||
};
|
||||
};
|
||||
|
||||
/** Решение при уходе с вкладки «ОСВ»: `perform` — сводки ещё нет, запросить; `warn` — показать модалку; `go` — перейти. */
|
||||
export type OsvLeaveDecision = 'perform' | 'warn' | 'go';
|
||||
|
||||
export const decideOsvLeave = (args: {
|
||||
summary: Pick<OsvSummaryOut, 'counter_pairs'> | null;
|
||||
versionId: string;
|
||||
status: ConsStatus | undefined;
|
||||
storage: Pick<Storage, 'getItem'> | null;
|
||||
}): OsvLeaveDecision => {
|
||||
const { summary, versionId, status, storage } = args;
|
||||
// закрытой версии модалка не нужна — и запрашивать нечего
|
||||
if (status && FINISHED_STATUSES.includes(status)) return 'go';
|
||||
if (summary === null) return 'perform';
|
||||
return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go';
|
||||
};
|
||||
|
||||
@ -2,9 +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 { getOsvSummary } from '../../services/api/osv';
|
||||
import type { CounterPairOut, OsvSummaryOut } from '../../types/api';
|
||||
import type { ConsStatus } from '../../types/consolidations/common_types';
|
||||
import { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations';
|
||||
import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations';
|
||||
import { pairsViewSearch } from './counterPairs';
|
||||
|
||||
const TAB_CONFIG = [
|
||||
@ -72,12 +73,36 @@ export function OsvTabs({ summary, versionId, status }: Props) {
|
||||
navigate(to, options);
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string | number) => {
|
||||
// сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал)
|
||||
const [fetched, setFetched] = useState<OsvSummaryOut | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const shownPairs = (summary ?? fetched)?.counter_pairs ?? [];
|
||||
|
||||
const handleTabChange = async (value: string | number) => {
|
||||
const newKey = String(value);
|
||||
if (newKey === activeKey) return;
|
||||
const pairs = summary?.counter_pairs ?? [];
|
||||
if (activeKey === 'osv' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) {
|
||||
rememberCounterPairsShown(versionId, pairs, safeSessionStorage());
|
||||
if (newKey === activeKey || checking) return;
|
||||
if (activeKey !== 'osv') {
|
||||
go(newKey);
|
||||
return;
|
||||
}
|
||||
const storage = safeSessionStorage();
|
||||
let known = summary ?? fetched;
|
||||
let decision = decideOsvLeave({ summary: known, versionId, status, storage });
|
||||
if (decision === 'perform') {
|
||||
setChecking(true);
|
||||
try {
|
||||
known = await getOsvSummary(versionId);
|
||||
setFetched(known);
|
||||
decision = decideOsvLeave({ summary: known, versionId, status, storage });
|
||||
} catch {
|
||||
// не удалось проверить — не держим пользователя на вкладке
|
||||
decision = 'go';
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
if (decision === 'warn' && known) {
|
||||
rememberCounterPairsShown(versionId, known.counter_pairs, storage);
|
||||
setPending(newKey);
|
||||
} else {
|
||||
go(newKey);
|
||||
@ -110,13 +135,14 @@ export function OsvTabs({ summary, versionId, status }: Props) {
|
||||
<Segmented
|
||||
value={activeKey}
|
||||
onChange={handleTabChange}
|
||||
disabled={checking}
|
||||
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
|
||||
options={options}
|
||||
/>
|
||||
<CounterPairsModal
|
||||
open={pending !== null}
|
||||
pairs={summary?.counter_pairs ?? []}
|
||||
unmappedTotal={summary?.unmapped_total ?? 0}
|
||||
pairs={shownPairs}
|
||||
unmappedTotal={(summary ?? fetched)?.unmapped_total ?? 0}
|
||||
onShow={() => {
|
||||
setPending(null);
|
||||
navigate(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CounterPairOut } from '../../types/api';
|
||||
import { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs';
|
||||
import { pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs';
|
||||
|
||||
const pair = (holder: string, missing: string): CounterPairOut => ({
|
||||
holder_id: holder,
|
||||
@ -15,7 +15,16 @@ const pair = (holder: string, missing: string): CounterPairOut => ({
|
||||
describe('readPairsView / pairsViewSearch', () => {
|
||||
it('table по умолчанию', () => {
|
||||
expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null });
|
||||
expect(pairsViewSearch({ view: 'table', holder: 'a', missing: 'b' })).toBe('');
|
||||
expect(pairsViewSearch({ view: 'table', holder: null, missing: null })).toBe('');
|
||||
});
|
||||
it('пара сохраняется в режиме «Свод» (T5.5)', () => {
|
||||
const v = { view: 'table' as const, holder: 'a', missing: 'b' };
|
||||
const s = pairsViewSearch(v);
|
||||
expect(s).toBe('?holder=a&missing=b');
|
||||
expect(readPairsView(new URLSearchParams(s))).toEqual(v);
|
||||
// переключение «Свод» → «Встречные записи» возвращает ту же пару
|
||||
expect(switchPairsView({ view: 'pairs', holder: 'a', missing: 'b' }, 'table')).toEqual(v);
|
||||
expect(switchPairsView(v, 'pairs')).toEqual({ view: 'pairs', holder: 'a', missing: 'b' });
|
||||
});
|
||||
it('туда-обратно', () => {
|
||||
const v = { view: 'pairs' as const, holder: 'a', missing: 'b' };
|
||||
|
||||
@ -4,18 +4,23 @@ import type { CounterPairOut } from '../../types/api';
|
||||
export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null };
|
||||
|
||||
/** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */
|
||||
export const readPairsView = (search: URLSearchParams): PairsView =>
|
||||
search.get('view') === 'pairs'
|
||||
? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') }
|
||||
: { view: 'table', holder: null, missing: null };
|
||||
export const readPairsView = (search: URLSearchParams): PairsView => ({
|
||||
view: search.get('view') === 'pairs' ? 'pairs' : 'table',
|
||||
holder: search.get('holder'),
|
||||
missing: search.get('missing'),
|
||||
});
|
||||
|
||||
/** Строка запроса для режима; у таблицы пусто. */
|
||||
/** Переключение «Свод» / «Встречные записи»: выбранная пара не теряется. */
|
||||
export const switchPairsView = (v: PairsView, view: PairsView['view']): PairsView => ({ ...v, view });
|
||||
|
||||
/** Строка запроса для режима; пара (`holder`/`missing`) хранится и в режиме «Свод» — для возврата. */
|
||||
export const pairsViewSearch = (v: PairsView): string => {
|
||||
if (v.view !== 'pairs') return '';
|
||||
const params = new URLSearchParams({ view: 'pairs' });
|
||||
const params = new URLSearchParams();
|
||||
if (v.view === 'pairs') params.set('view', 'pairs');
|
||||
if (v.holder) params.set('holder', v.holder);
|
||||
if (v.missing) params.set('missing', v.missing);
|
||||
return `?${params.toString()}`;
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : '';
|
||||
};
|
||||
|
||||
/** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */
|
||||
|
||||
@ -2,8 +2,8 @@ import { message } from 'antd';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv';
|
||||
import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut } from '../../types/api';
|
||||
import { type OsvFilterDraft, applyFilterPatch } from './osvFilterState';
|
||||
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
|
||||
import { type OsvFilterDraft, type OsvFilterState, applyFilterPatch } from './osvFilterState';
|
||||
|
||||
export const OSV_PAGE_SIZE = 500;
|
||||
|
||||
@ -17,9 +17,14 @@ export function osvErrorText(e: unknown): string {
|
||||
return 'Не удалось получить сводную ОСВ';
|
||||
}
|
||||
|
||||
export function useConsolidatedOsv(versionId: string) {
|
||||
const [filters, setFilters] = useState<OsvFilters>({});
|
||||
const [page, setPage] = useState(1);
|
||||
/**
|
||||
* @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается).
|
||||
* Фильтры и страница — одно состояние: смена фильтра с возвратом на 1-ю страницу даёт один запрос, а не два.
|
||||
*/
|
||||
export function useConsolidatedOsv(versionId: string, pairsMode = false) {
|
||||
const [query, setQuery] = useState<OsvFilterState>({ filters: {}, page: 1 });
|
||||
const { filters, page } = query;
|
||||
const setPage = useCallback((p: number) => setQuery((q) => ({ ...q, page: p })), []);
|
||||
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [summary, setSummary] = useState<OsvSummaryOut | null>(null);
|
||||
@ -71,18 +76,17 @@ export function useConsolidatedOsv(versionId: string) {
|
||||
}, [versionId, filters, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pairsMode) return;
|
||||
void load();
|
||||
}, [load]);
|
||||
}, [load, pairsMode]);
|
||||
|
||||
const patchFilters = (patch: OsvFilterDraft) => {
|
||||
const next = applyFilterPatch({ filters, page }, patch);
|
||||
setFilters(next.filters);
|
||||
setPage(next.page);
|
||||
setQuery((q) => applyFilterPatch(q, patch));
|
||||
};
|
||||
|
||||
/** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */
|
||||
const refresh = () => {
|
||||
void load();
|
||||
if (!pairsMode) void load();
|
||||
void loadSummary();
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Segmented, Select, message } from 'antd';
|
||||
import { Alert, Button, Segmented, Select, Spin, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ApiError } from '../../api/client';
|
||||
@ -11,7 +11,7 @@ import { CounterPairsView } from '../ConsolidatedOsv/CounterPairsView';
|
||||
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
|
||||
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
|
||||
import { OsvTable } from '../ConsolidatedOsv/OsvTable';
|
||||
import { pairsViewSearch, readPairsView, selectPair } from '../ConsolidatedOsv/counterPairs';
|
||||
import { pairsViewSearch, readPairsView, selectPair, switchPairsView } from '../ConsolidatedOsv/counterPairs';
|
||||
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
|
||||
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
|
||||
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
|
||||
@ -28,7 +28,11 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
||||
|
||||
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
|
||||
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
|
||||
const osv = useConsolidatedOsv(version_id ?? '');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const pairsView = readPairsView(searchParams);
|
||||
const inPairs = pairsView.view === 'pairs';
|
||||
const osv = useConsolidatedOsv(version_id ?? '', inPairs);
|
||||
const s = osv.summary;
|
||||
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
|
||||
|
||||
const download = async () => {
|
||||
@ -46,12 +50,8 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const s = osv.summary;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const pairsView = readPairsView(searchParams);
|
||||
const pairs = s?.counter_pairs ?? [];
|
||||
const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing);
|
||||
const inPairs = pairsView.view === 'pairs';
|
||||
const setView = (v: Parameters<typeof pairsViewSearch>[0]) => setSearchParams(pairsViewSearch(v), { replace: true });
|
||||
const unmapped = s?.unmapped_total ?? 0;
|
||||
const onlyUnmapped = osv.filters.unmapped === true;
|
||||
@ -71,6 +71,19 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
||||
// вне вкладки «ОСВ» предупреждать не о чем
|
||||
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
|
||||
|
||||
const pairsBody = s ? (
|
||||
<CounterPairsView
|
||||
versionId={version_id ?? ''}
|
||||
pairs={pairs}
|
||||
selected={selectedPair}
|
||||
numFormat={format}
|
||||
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
|
||||
onMapped={osv.refresh}
|
||||
/>
|
||||
) : (
|
||||
!osv.error && <Spin style={{ alignSelf: 'center', margin: 24 }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main
|
||||
@ -110,12 +123,11 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
||||
</div>
|
||||
<Segmented
|
||||
value={pairsView.view}
|
||||
onChange={(v) =>
|
||||
setView(v === 'pairs' ? { view: 'pairs', holder: null, missing: null } : { view: 'table', holder: null, missing: null })
|
||||
}
|
||||
onChange={(v) => setView(switchPairsView(pairsView, v === 'pairs' ? 'pairs' : 'table'))}
|
||||
options={[
|
||||
{ label: 'Свод', value: 'table' },
|
||||
{ label: `Встречные записи (${pairs.length})`, value: 'pairs' },
|
||||
// пока сводки нет — без числа
|
||||
{ label: s ? `Встречные записи (${pairs.length})` : 'Встречные записи', value: 'pairs' },
|
||||
]}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
@ -149,16 +161,7 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{inPairs ? (
|
||||
s && (
|
||||
<CounterPairsView
|
||||
versionId={version_id ?? ''}
|
||||
pairs={pairs}
|
||||
selected={selectedPair}
|
||||
numFormat={format}
|
||||
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
|
||||
onMapped={osv.refresh}
|
||||
/>
|
||||
)
|
||||
pairsBody
|
||||
) : (
|
||||
<>
|
||||
<OsvGaps gaps={s?.gaps ?? []} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user