diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 76bd120..932a483 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -6,11 +6,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; +import { getColumnVirtualizerOptions } from '../../components/RealtimeTable/utils/columnLayoutUtils'; import { PrimaryButton } from '../../components/common/Buttons/Buttons'; import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; +import { SVOD2_TABLE_CONTAINER_SX, createColumnClassMap, getEstimateTypeClassName } from './svod2Styles'; const SHEETS = [ { value: 'GO', label: 'ССП' }, @@ -27,10 +29,9 @@ const ROW_VIRTUALIZER_OPTIONS = { estimateSize: () => ROW_HEIGHT, measureElement: (element) => element?.offsetHeight || ROW_HEIGHT, }; -const COLUMN_BAND_COLORS = { - head: ['#f8f9fa', '#f1f7f3'], - body: ['#ffffff', '#f8fbf9'], -}; + +const COLUMN_VIRTUALIZER_OVERSCAN = 3; +const getSvod2ColumnVirtualizerOptions = (args) => ({ ...getColumnVirtualizerOptions(args), overscan: COLUMN_VIRTUALIZER_OVERSCAN }); const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data); @@ -73,11 +74,6 @@ const CARRYOVER_HEADERS = { 2: 'Корректировка из 2 квартала в 3–4 кварталы', 3: 'Корректировка из 3 квартала в 4 квартал', }; -const ESTIMATE_TYPE_STYLES = { - АХР: { backgroundColor: '#ecf9f0', borderColor: '#a7d9b8', color: '#08783e' }, - Операц: { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' }, - КВ: { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' }, -}; const createCorrectionColumns = (quarter) => ({ id: `q${quarter}.correction`, @@ -165,28 +161,7 @@ const createColumns = (sheet) => [ Cell: ({ cell }) => { const value = cell.getValue(); if (!value) return '—'; - const colors = ESTIMATE_TYPE_STYLES[value] || { - backgroundColor: '#f3f4f6', - borderColor: '#d1d5db', - color: '#4b5563', - }; - return ( - - {value} - - ); + return {value}; }, }), textColumn('smeta_direction', 'Направление', ['header', 'smeta_direction'], 10, { enableColumnFilter: true, filterVariant: 'select' }), @@ -196,28 +171,7 @@ const createColumns = (sheet) => [ Cell: ({ cell }) => { const value = cell.getValue(); if (!value) return '—'; - return ( - - {value} - - ); + return {value}; }, }), textColumn('name', 'Наименование статьи', ['header', 'name'], 20), @@ -295,30 +249,6 @@ const createColumns = (sheet) => [ createActualColumns(4), ]; -const createColumnBandMap = (columns) => { - const bands = new Map(); - const addColumn = (column, band) => { - bands.set(column.id, band); - column.columns?.forEach((childColumn) => addColumn(childColumn, band)); - }; - columns.forEach((column, index) => addColumn(column, index % 2)); - return bands; -}; - -const createColumnGroupEndSet = (columns) => { - const groupEnds = new Set(); - const getLastLeafId = (column) => { - if (!column.columns?.length) return column.id; - return getLastLeafId(column.columns.at(-1)); - }; - columns.forEach((column) => { - if (!column.columns?.length) return; - groupEnds.add(column.id); - groupEnds.add(getLastLeafId(column)); - }); - return groupEnds; -}; - const disableGroupColumnFilters = (columns) => columns.map((column) => { if (!column.columns?.length) return column; @@ -336,8 +266,7 @@ const getColumnConfig = (sheet) => { const columns = disableGroupColumnFilters(createColumns(sheet)); COLUMN_CONFIG_CACHE.set(sheet, { columns, - columnBands: createColumnBandMap(columns), - columnGroupEnds: createColumnGroupEndSet(columns), + columnClassNames: createColumnClassMap(columns), }); } @@ -460,7 +389,7 @@ export default function Svod2Page() { const normalizedSearch = search.trim().toLowerCase(); return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; }, [hasCurrentData, rows, search]); - const { columns: rawColumns, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]); + const { columns: rawColumns, columnClassNames } = useMemo(() => getColumnConfig(sheet), [sheet]); const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]); const table = useMaterialReactTable({ columns, @@ -475,6 +404,8 @@ export default function Svod2Page() { enableHiding: false, enablePagination: false, enableRowVirtualization: true, + enableColumnVirtualization: true, + columnVirtualizerOptions: getSvod2ColumnVirtualizerOptions, enableTopToolbar: false, enableBottomToolbar: false, enableSorting: false, @@ -497,15 +428,7 @@ export default function Svod2Page() { flexDirection: 'column', }, }, - muiTableContainerProps: { - sx: { - flex: 1, - overflow: 'auto', - minHeight: 0, - height: '100%', - '& thead': { position: 'sticky', top: 0, zIndex: 10 }, - }, - }, + muiTableContainerProps: { sx: SVOD2_TABLE_CONTAINER_SX }, muiTableHeadProps: { sx: { '& tr:not(:last-of-type) > th > .MuiCollapse-root': { @@ -513,37 +436,9 @@ export default function Svod2Page() { }, }, }, - muiTableHeadCellProps: ({ column }) => ({ - sx: { - position: 'sticky', - top: 0, - boxShadow: 'none', - backgroundColor: COLUMN_BAND_COLORS.head[columnBands.get(column.id) ?? 0], - borderRight: columnGroupEnds.has(column.id) ? '0.1875rem solid #9eafa3' : '0.0625rem solid #e5e7eb', - color: '#4a5565', - fontSize: '0.75rem', - fontWeight: 700, - lineHeight: 1.2, - px: '0.75rem', - py: '0.5rem', - whiteSpace: 'nowrap', - opacity: 1, - zIndex: column.getIsPinned() ? 5 : undefined, - }, - }), + muiTableHeadCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), muiTableHeadRowProps: { sx: { boxShadow: 'none' } }, - muiTableBodyCellProps: ({ column }) => ({ - sx: { - backgroundColor: COLUMN_BAND_COLORS.body[columnBands.get(column.id) ?? 0], - borderRight: columnGroupEnds.has(column.id) ? '0.1875rem solid #9eafa3' : '0.0625rem solid #edf0f2', - fontSize: '0.8125rem', - px: '0.75rem', - py: '0.5rem', - whiteSpace: 'nowrap', - opacity: 1, - zIndex: column.getIsPinned() ? 2 : undefined, - }, - }), + muiTableBodyCellProps: ({ column }) => ({ className: columnClassNames.get(column.id) }), localization: { ...MRT_Localization_RU, noRecordsToDisplay: 'Нет данных для выбранных параметров', diff --git a/web/src/pages/Svod2Page/svod2Styles.js b/web/src/pages/Svod2Page/svod2Styles.js new file mode 100644 index 0000000..09fffd9 --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Styles.js @@ -0,0 +1,117 @@ +// Чистые функции и статичные стили свода 2. Без JSX, чтобы тестироваться через node --test. + +export const createColumnBandMap = (columns) => { + const bands = new Map(); + const addColumn = (column, band) => { + bands.set(column.id, band); + column.columns?.forEach((childColumn) => addColumn(childColumn, band)); + }; + columns.forEach((column, index) => addColumn(column, index % 2)); + return bands; +}; + +export const createColumnGroupEndSet = (columns) => { + const groupEnds = new Set(); + const getLastLeafId = (column) => { + if (!column.columns?.length) return column.id; + return getLastLeafId(column.columns.at(-1)); + }; + columns.forEach((column) => { + if (!column.columns?.length) return; + groupEnds.add(column.id); + groupEnds.add(getLastLeafId(column)); + }); + return groupEnds; +}; + +export const getSvod2CellClassName = (columnId, bands, groupEnds) => { + const classNames = []; + if (bands.get(columnId) === 1) classNames.push('svod2-band-1'); + if (groupEnds.has(columnId)) classNames.push('svod2-group-end'); + return classNames.length ? classNames.join(' ') : undefined; +}; + +// columnId -> className, считается один раз на лист, а не на каждую ячейку. +export const createColumnClassMap = (columns) => { + const bands = createColumnBandMap(columns); + const groupEnds = createColumnGroupEndSet(columns); + const classMap = new Map(); + for (const columnId of bands.keys()) classMap.set(columnId, getSvod2CellClassName(columnId, bands, groupEnds)); + return classMap; +}; + +const ESTIMATE_TYPE_CLASSES = { + АХР: 'svod2-badge-ahr', + Операц: 'svod2-badge-oper', + КВ: 'svod2-badge-kv', +}; + +export const getEstimateTypeClassName = (value) => `svod2-badge ${ESTIMATE_TYPE_CLASSES[value] || 'svod2-badge-default'}`; + +const GROUP_END_BORDER = '0.1875rem solid #9eafa3'; + +// Одно правило на контейнере вместо sx в каждой ячейке и заголовке. +// Специфичность (класс контейнера + элемент) выше, чем у sx самой MRT-ячейки. +export const SVOD2_TABLE_CONTAINER_SX = { + flex: 1, + overflow: 'auto', + minHeight: 0, + height: '100%', + '& thead': { position: 'sticky', top: 0, zIndex: 10 }, + '& td': { + backgroundColor: '#ffffff', + borderRight: '0.0625rem solid #edf0f2', + fontSize: '0.8125rem', + padding: '0.5rem 0.75rem', + whiteSpace: 'nowrap', + opacity: 1, + }, + '& td.svod2-band-1': { backgroundColor: '#f8fbf9' }, + '& td.svod2-group-end': { borderRight: GROUP_END_BORDER }, + '& td[data-pinned="true"]': { zIndex: 2 }, + '& th': { + position: 'sticky', + top: 0, + boxShadow: 'none', + backgroundColor: '#f8f9fa', + borderRight: '0.0625rem solid #e5e7eb', + color: '#4a5565', + fontSize: '0.75rem', + fontWeight: 700, + lineHeight: 1.2, + padding: '0.5rem 0.75rem', + whiteSpace: 'nowrap', + opacity: 1, + }, + '& th.svod2-band-1': { backgroundColor: '#f1f7f3' }, + '& th.svod2-group-end': { borderRight: GROUP_END_BORDER }, + '& th[data-pinned="true"]': { zIndex: 5 }, + '& .svod2-badge': { + display: 'inline-block', + padding: '0.25rem 0.625rem', + border: '0.0625rem solid', + borderRadius: '0.5rem', + fontWeight: 600, + lineHeight: 1.35, + whiteSpace: 'nowrap', + }, + '& .svod2-badge-ahr': { backgroundColor: '#ecf9f0', borderColor: '#a7d9b8', color: '#08783e' }, + '& .svod2-badge-oper': { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' }, + '& .svod2-badge-kv': { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' }, + '& .svod2-badge-default': { backgroundColor: '#f3f4f6', borderColor: '#d1d5db', color: '#4b5563' }, + '& .svod2-org-badge': { + display: 'inline-block', + maxWidth: '100%', + padding: '0.25rem 0.625rem', + border: '0.0625rem solid #a7d9b8', + borderRadius: '0.5rem', + backgroundColor: '#ecf9f0', + color: '#08783e', + fontWeight: 600, + lineHeight: 1.35, + textTransform: 'uppercase', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, +}; diff --git a/web/src/pages/Svod2Page/svod2Styles.test.js b/web/src/pages/Svod2Page/svod2Styles.test.js new file mode 100644 index 0000000..ffdb98c --- /dev/null +++ b/web/src/pages/Svod2Page/svod2Styles.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + SVOD2_TABLE_CONTAINER_SX, + createColumnBandMap, + createColumnClassMap, + createColumnGroupEndSet, + getEstimateTypeClassName, + getSvod2CellClassName, +} from './svod2Styles.js'; + +const columns = [ + { id: 'a' }, + { id: 'g1', columns: [{ id: 'g1.x' }, { id: 'g1.sub', columns: [{ id: 'g1.sub.y' }, { id: 'g1.sub.z' }] }] }, + { id: 'b' }, + { id: 'g2', columns: [{ id: 'g2.x' }, { id: 'g2.y' }] }, +]; + +test('bands alternate by top-level column and are inherited by children', () => { + const bands = createColumnBandMap(columns); + assert.equal(bands.get('a'), 0); + assert.equal(bands.get('g1'), 1); + assert.equal(bands.get('g1.sub.z'), 1); + assert.equal(bands.get('b'), 0); + assert.equal(bands.get('g2.y'), 1); +}); + +test('group ends contain the group id and its last leaf only', () => { + const ends = createColumnGroupEndSet(columns); + assert.deepEqual([...ends].sort(), ['g1', 'g1.sub.z', 'g2', 'g2.y']); +}); + +test('cell class name combines band and group-end', () => { + const bands = createColumnBandMap(columns); + const ends = createColumnGroupEndSet(columns); + assert.equal(getSvod2CellClassName('a', bands, ends), undefined); + assert.equal(getSvod2CellClassName('g1.x', bands, ends), 'svod2-band-1'); + assert.equal(getSvod2CellClassName('g1.sub.z', bands, ends), 'svod2-band-1 svod2-group-end'); + assert.equal(getSvod2CellClassName('unknown', bands, ends), undefined); +}); + +test('class map is precomputed for every column id', () => { + const map = createColumnClassMap(columns); + assert.equal(map.size, 10); + assert.equal(map.get('g2.y'), 'svod2-band-1 svod2-group-end'); + assert.equal(map.get('a'), undefined); +}); + +test('estimate type class falls back to default', () => { + assert.equal(getEstimateTypeClassName('АХР'), 'svod2-badge svod2-badge-ahr'); + assert.equal(getEstimateTypeClassName('Прочее'), 'svod2-badge svod2-badge-default'); +}); + +test('container styles define cell, band, group-end and pinned rules', () => { + for (const key of [ + '& td', + '& th', + '& td.svod2-band-1', + '& th.svod2-band-1', + '& td.svod2-group-end', + '& th.svod2-group-end', + '& td[data-pinned="true"]', + '& th[data-pinned="true"]', + ]) { + assert.ok(SVOD2_TABLE_CONTAINER_SX[key], key); + } +});