diff --git a/openspec/specs/document-review-ui/spec.md b/openspec/specs/document-review-ui/spec.md index 4191a47..b8d1cc4 100644 --- a/openspec/specs/document-review-ui/spec.md +++ b/openspec/specs/document-review-ui/spec.md @@ -79,3 +79,15 @@ column_id, amount}`, ячейка достроенной строки (`id = nul #### Scenario: Подтверждение ОСВ - **WHEN** аналитик подтверждает ОСВ-документ `NEEDS_CHECK` - **THEN** документ становится «Загружен», версия, к которой он подходит, переходит в `DATA_CORRECTION` + +### Requirement: Виртуализация таблицы ОСВ-документа +Таблица строк ОСВ-документа SHALL быть виртуализирована: в DOM находятся только видимые строки, остальные не +отрисовываются. Строки по-прежнему загружаются целиком, поиск по контрагенту/ИНН и фильтр по субсчёту — клиентские; +серверной пагинации нет. Высота области прокрутки считается числом по высоте окна и пересчитывается при его изменении. +Строка «Итого по документу» закреплена внизу и выровнена по колонкам, двухъярусная шапка и колонка «Субсчёт» +закреплены, горизонтальная прокрутка сохраняется. ОСВ любого размера (десятки тысяч строк) SHALL открываться без +падения вкладки. + +#### Scenario: Большая ОСВ +- **WHEN** аналитик открывает ОСВ-документ на ~29 000 строк +- **THEN** документ открывается, число строк в DOM не зависит от размера документа, прокрутка до конца, поиск и фильтр по субсчёту работают, «Итого по документу» совпадает с суммой по всему документу diff --git a/src/widgets/DocumentReview/OsvDocument.tsx b/src/widgets/DocumentReview/OsvDocument.tsx index bb0636b..003a5ed 100644 --- a/src/widgets/DocumentReview/OsvDocument.tsx +++ b/src/widgets/DocumentReview/OsvDocument.tsx @@ -9,6 +9,7 @@ import { formatAmount } from '../../utils/money'; import { confirmButtonStyle } from './ReviewCard'; import { confirmErrorInfo } from './confirmError'; import { AMOUNT_KEYS, type AmountKey, type OsvRow, flattenEntries } from './osvRows'; +import { osvBodyHeight } from './osvTableSize'; const money = (key: AmountKey, title: string) => ({ key, @@ -48,6 +49,17 @@ interface ConfirmProblem { 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 }) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); @@ -66,6 +78,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on }; }, [document.id]); + const bodyHeight = useBodyHeight(); const [query, setQuery] = useState(''); const [account, setAccount] = useState(); const table = useMemo(() => flattenEntries(entries), [entries]); @@ -143,6 +156,8 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on /> )} + // virtual: в DOM только видимые строки (ОСВ на десятки тысяч строк иначе роняет вкладку); нужны числовые scroll.x и scroll.y + virtual bordered size='small' rowKey='key' @@ -151,7 +166,7 @@ export function OsvDocument({ document, onChanged }: { document: DocumentOut; on dataSource={rows} pagination={false} locale={{ emptyText: entries.length > 0 ? 'По заданным фильтрам строк нет' : 'В документе нет строк' }} - scroll={{ y: 'calc(100vh - 340px)', x: table.hasDepartments ? 1540 : 1380 }} + scroll={{ y: bodyHeight, x: table.hasDepartments ? 1540 : 1380 }} summary={() => entries.length > 0 && ( diff --git a/src/widgets/DocumentReview/osvTableSize.test.ts b/src/widgets/DocumentReview/osvTableSize.test.ts new file mode 100644 index 0000000..856f3ca --- /dev/null +++ b/src/widgets/DocumentReview/osvTableSize.test.ts @@ -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); + }); +}); diff --git a/src/widgets/DocumentReview/osvTableSize.ts b/src/widgets/DocumentReview/osvTableSize.ts new file mode 100644 index 0000000..c98ec69 --- /dev/null +++ b/src/widgets/DocumentReview/osvTableSize.ts @@ -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)); +}