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);
+ }
+});