Merge pull request 'Просмотр ОСВ: виртуализация таблицы (#38, AURORA-1665)' (#41)

This commit is contained in:
aep 2026-10-17 13:46:18 +03:00
commit 783c3f0b17
4 changed files with 63 additions and 3 deletions

View File

@ -89,3 +89,15 @@ column_id, amount}`, ячейка достроенной строки (`id = nul
#### Scenario: Подтверждение ОСВ #### Scenario: Подтверждение ОСВ
- **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK` - **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK`
- **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION` - **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION`
### Requirement: Виртуализация таблицы ОСВ-документа
Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не
отрисовываются. Строки по-прежнему загружаются целиком, поиск по аналитике (и ИНН) и фильтр по субсчёту — клиентские;
серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении.
Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт»
закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без
падения вкладки.
#### Scenario: Большая ОСВ
- **WHEN** аналитик открывает ОСВ-документ на ~29 000 строк
- **THEN** документ открывается, число строк в DOM не зависит от размера документа, прокрутка до конца, поиск и фильтр по субсчёту работают, «Итого по документу» совпадает с суммой по всему документу

View File

@ -9,13 +9,17 @@ import { formatAmount } from '../../utils/money';
import { confirmButtonStyle } from './ReviewCard'; import { confirmButtonStyle } from './ReviewCard';
import { confirmErrorInfo } from './confirmError'; import { confirmErrorInfo } from './confirmError';
import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows'; import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows';
import { osvBodyHeight } from './osvTableSize';
/** Ширина колонки суммы: «17 780 468 796,56» помещается в одну строку, «Итого по документу» не переносится и не обрезается. */
const MONEY_WIDTH = 160;
const money = (key: AmountKey, title: string) => ({ const money = (key: AmountKey, title: string) => ({
key, key,
title, title,
dataIndex: key, dataIndex: key,
align: 'right' as const, align: 'right' as const,
width: 140, width: MONEY_WIDTH,
render: (v: string | null) => formatAmount(v, { zeroDash: true }), render: (v: string | null) => formatAmount(v, { zeroDash: true }),
}); });
@ -28,7 +32,7 @@ interface ColumnsOptions {
analyticsHint: string | null; analyticsHint: string | null;
} }
/** Ширина колонок до сумм (Субсчёт, [Подразделение], аналитика, [ИНН]) и всей таблицы с шестью суммами по 140. */ /** Ширина колонок до сумм (Субсчёт, [Подразделение], аналитика, [ИНН]) и всей таблицы с шестью суммами по MONEY_WIDTH. */
const widthBeforeAmounts = ({ hasDepartments, hasCounterparties }: ColumnsOptions) => const widthBeforeAmounts = ({ hasDepartments, hasCounterparties }: ColumnsOptions) =>
100 + (hasDepartments ? 160 : 0) + 320 + (hasCounterparties ? 120 : 0); 100 + (hasDepartments ? 160 : 0) + 320 + (hasCounterparties ? 120 : 0);
@ -66,6 +70,17 @@ interface ConfirmProblem {
failures: FailedCheckOut[]; failures: FailedCheckOut[];
} }
/** Высота области прокрутки числом, пересчитывается при изменении размера окна. */
function useBodyHeight(): number {
const [height, setHeight] = useState(() => osvBodyHeight(window.innerHeight));
useEffect(() => {
const onResize = () => setHeight(osvBodyHeight(window.innerHeight));
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return height;
}
export function OsvDocument({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) { export function OsvDocument({ document, onChanged }: { document: DocumentOut; onChanged: () => void }) {
const [entries, setEntries] = useState<EntryOut[]>([]); const [entries, setEntries] = useState<EntryOut[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -84,6 +99,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
}; };
}, [document.id]); }, [document.id]);
const bodyHeight = useBodyHeight();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [account, setAccount] = useState<string | undefined>(); const [account, setAccount] = useState<string | undefined>();
const table = useMemo(() => flattenEntries(entries), [entries]); const table = useMemo(() => flattenEntries(entries), [entries]);
@ -170,6 +186,8 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
/> />
)} )}
<Table<OsvRow> <Table<OsvRow>
// virtual: в DOM только видимые строки (ОСВ на десятки тысяч строк иначе роняет вкладку); нужны числовые scroll.x и scroll.y
virtual
bordered bordered
size='small' size='small'
rowKey='key' rowKey='key'
@ -178,7 +196,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on
dataSource={rows} dataSource={rows}
pagination={false} pagination={false}
locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }} locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }}
scroll={{ y: 'calc(100vh - 340px)', x: widthBeforeAmounts(columnsOptions) + AMOUNT_KEYS.length * 140 }} scroll={{ y: bodyHeight, x: widthBeforeAmounts(columnsOptions) + AMOUNT_KEYS.length * MONEY_WIDTH }}
summary={() => summary={() =>
entries.length > 0 && ( entries.length > 0 && (
<Table.Summary fixed> <Table.Summary fixed>

View File

@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { OSV_MIN_BODY_HEIGHT, osvBodyHeight } from './osvTableSize';
describe('osvBodyHeight', () => {
it('вычитает высоту обвязки из высоты окна', () => {
expect(osvBodyHeight(1000)).toBe(660);
});
it('округляет вниз до целого', () => {
expect(osvBodyHeight(1000.7)).toBe(660);
});
it('в низком окне не опускается ниже минимума', () => {
expect(osvBodyHeight(400)).toBe(OSV_MIN_BODY_HEIGHT);
});
it('на нечисловом значении возвращает минимум', () => {
expect(osvBodyHeight(Number.NaN)).toBe(OSV_MIN_BODY_HEIGHT);
});
});

View File

@ -0,0 +1,13 @@
/** Высота тела таблицы не меньше этой, даже в низком окне. */
export const OSV_MIN_BODY_HEIGHT = 240;
/** Сколько высоты окна занимает всё над и под телом таблицы: шапка страницы, кнопки, поиск, шапка и «Итого» таблицы. */
export const OSV_CHROME_HEIGHT = 340;
/**
* Высота области прокрутки таблицы ОСВ числом (виртуальной таблице antd нужен числовой scroll.y, calc() не подходит).
* Повторяет прежнее `calc(100vh - 340px)`, но не ниже минимума; мусорное значение окна даёт минимум.
*/
export function osvBodyHeight(viewportHeight: number): number {
if (!Number.isFinite(viewportHeight)) return OSV_MIN_BODY_HEIGHT;
return Math.max(OSV_MIN_BODY_HEIGHT, Math.floor(viewportHeight - OSV_CHROME_HEIGHT));
}