diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 7ada266..b1267fd 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,5 +1,6 @@ import React, { useMemo, useCallback } from 'react'; import { useCellLock } from '../../contexts/RealtimeContext'; +import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils'; const BASE_CELL_STYLES = { width: '100%', @@ -64,17 +65,6 @@ const CONTAINER_STYLES = { wordBreak: 'break-word', }; -const TEXT_COLUMN_IDS = new Set([ - 'data.header.num_group', - 'data.nomenclature_group_id', - 'data.header.item_id', - 'data.article_id', - 'data.code_razdela', - 'data.header.num_group_id', - 'data.section_no', - 'data.expense_item_code', -]); - const getColorBrightness = (hexColor) => { if (!hexColor) return 255; const color = hexColor.replace('#', ''); @@ -137,7 +127,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) => }); }; -const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => { +const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayFactor = 1 }) => { const cellKey = `${row.id}_${column.id}`; const isLocked = useCellLock(cellKey); @@ -149,7 +139,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !TEXT_COLUMN_IDS.has(column.id); let display = String(value || ''); if (isNum) { - display = formatNumber(Number(display) / displayDivisor, false); + display = formatNumber(Number(display) * displayFactor, false); } if (isVspDropdown && value != null) { @@ -160,7 +150,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, } return { rawValue: value, displayValue: display, isNumeric: isNum }; - }, [cell, column.id, isVspDropdown, vspOptions, displayDivisor]); + }, [cell, column.id, isVspDropdown, vspOptions, displayFactor]); const highlightedContent = useMemo(() => { const searchQueries = [globalFilter, columnFilter]; @@ -221,7 +211,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => { prevProps.isInvalid === nextProps.isInvalid && prevProps.isEditable === nextProps.isEditable && prevProps.vspOptions === nextProps.vspOptions && - prevProps.displayDivisor === nextProps.displayDivisor + prevProps.displayFactor === nextProps.displayFactor ); }); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index bbde0fc..1d88ba2 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -162,7 +162,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const columnVirtualizerRef = useRef(null); const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(); - const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination`; + const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`; const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey); useEffect(() => { @@ -265,12 +265,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onCellUpdateError: handleCellUpdateError, isCellInvalid, denomination, + sheetName, }); } catch (error) { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]); + }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination, sheetName]); // MRT запоминает индексы pinned-колонок до завершения асинхронной // загрузки конфигурации. Новая ссылка на columnPinning заставляет diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 3acceb7..ebacf0a 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -1,4 +1,4 @@ -import { Divider, IconButton, Stack, Tooltip } from '@mui/material'; +import { Divider, IconButton, Stack, Tooltip, Typography } from '@mui/material'; import { memo, useCallback, useMemo, useState } from 'react'; import { FORM_TYPE_OPTIONS } from '../../../constants/constants'; import { exportSheet, exportSheetProject } from '../../../utils/exportFile'; @@ -22,6 +22,7 @@ import { VisibleExpenseItems, } from '../../common/icons/icons'; import { collectLeafColumns } from '../utils/columnUtils'; +import { AHR_LIMIT_SHEET } from '../utils/moneyUtils'; import { GroupByObject } from './GroupByObject/GroupByObject'; import { Panel } from './SettingPanel.style'; import ValidationErrorsAlert from './ValidationErrorsAlert'; @@ -179,7 +180,14 @@ const SettingPanel = ({ - + {sheetName !== AHR_LIMIT_SHEET && ( + <> + + + Ввод сумм — в тыс. руб. + + + )} diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index cf557e0..e25ccd6 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react' import { createPortal } from 'react-dom'; import { useParams } from 'react-router'; import { useVspOptions } from './contexts/RealtimeContext'; -import { getDenominationDivisor } from '../common/useDenomination'; +import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils'; import { ROLES_NAME_ID } from '../../constants/constants'; import { blueColumn } from './constants/columnColors'; @@ -119,17 +119,27 @@ export const getTableColumns = ({ onCellUpdateError, onCellNumberClick, isCellInvalid, - denomination = 'rub', + denomination = 'thousand', + sheetName, }) => { - const displayDivisor = getDenominationDivisor(denomination); const columnColors = { ...columnsConfig.colors }; const columns = structuredClone(columnsConfig.columns); const cellPropsCache = new WeakMap(); const editPropsCache = new WeakMap(); + // factor === 1 означает «как в базе»: либо колонка не денежная, либо разрядность уже совпадает с базовой. + // На листе AHR_LIMIT нет селектора — значения всегда показываются как есть, независимо от сохранённой + // разрядности (там она по умолчанию 'thousand', а база листа — 'rub'). + const getCellDisplayFactor = (column, moneyUnit = getMoneyUnit(column.columnDef, sheetName)) => + moneyUnit && sheetName !== AHR_LIMIT_SHEET ? getDisplayFactor(moneyUnit, denomination) : 1; + const isEditable = (row, column) => { if (row.original?.row_type !== 'INPUT') return false; + const moneyUnit = getMoneyUnit(column.columnDef, sheetName); + // Разрядность, отличная от базовой «тыс. руб.», искажает значение — редактирование запрещено, + // чтобы случайно не записать домноженное/разделённое число. + if (moneyUnit !== null && getCellDisplayFactor(column, moneyUnit) !== 1) return false; if (userRoleId === ROLES_NAME_ID.admin) return true; const keyCol = column.id; if (columnsCurStage.includes(keyCol)) return true; @@ -142,6 +152,7 @@ export const getTableColumns = ({ const isInvalid = isCellInvalid?.(column.id, value, row.original) || false; const isEditableCell = isEditable(row, column); const backgroundColor = getColorCell(columnColors, column, row); + const displayFactor = getCellDisplayFactor(column); if (!cellPropsCache.has(row)) { cellPropsCache.set(row, {}); } @@ -156,7 +167,7 @@ export const getTableColumns = ({ cached.globalFilter === currentGlobalFilter && cached.columnFilter === currentColumnFilter && cached.value === value && - cached.displayDivisor === displayDivisor && + cached.displayFactor === displayFactor && cached.isInvalid === isInvalid && cached.backgroundColor === backgroundColor ) { @@ -174,8 +185,8 @@ export const getTableColumns = ({ backgroundColor, isInvalid, value, - displayDivisor, - _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`, + displayFactor, + _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayFactor}`, }; rowCache[key] = props; @@ -253,10 +264,13 @@ export const getTableColumns = ({ size: 50, enableEditing: false, Cell: ({ cell, row }) => { - const handleClick = useCallback((event) => { - event.stopPropagation(); - onCellNumberClick(row.id); - }, [row.id, onCellNumberClick]); + const handleClick = useCallback( + (event) => { + event.stopPropagation(); + onCellNumberClick(row.id); + }, + [row.id, onCellNumberClick], + ); return ; }, }; diff --git a/web/src/components/RealtimeTable/utils/moneyUtils.js b/web/src/components/RealtimeTable/utils/moneyUtils.js new file mode 100644 index 0000000..22c6b8f --- /dev/null +++ b/web/src/components/RealtimeTable/utils/moneyUtils.js @@ -0,0 +1,114 @@ +// Денежные единицы и разрядность отображения (AURORA-1899). +// +// Правило: в базе денежные значения хранятся в тысячах рублей (кроме явных +// исключений ниже). Этот модуль не импортирует конфиги колонок форм, чтобы +// оставаться пригодным для `node --test` без сборки проекта. + +// Единицы, доступные в селекторе разрядности, в рублях. +export const UNIT_IN_RUB = { + rub: 1, + thousand: 1e3, + million: 1e6, + billion: 1e9, +}; + +export const DEFAULT_DENOMINATION = 'thousand'; + +/** + * Разрешает значение разрядности, прочитанное из хранилища (может быть + * null/undefined/'' при отсутствии сохранённого значения или значением из + * старой схемы хранения). + */ +export const resolveDenomination = (storedValue) => storedValue || DEFAULT_DENOMINATION; + +/** + * Множитель для перевода значения из базовой единицы колонки (`baseUnit`) + * в выбранную пользователем разрядность (`denomination`). + * displayedValue = baseValue * getDisplayFactor(baseUnit, denomination) + */ +export const getDisplayFactor = (baseUnit, denomination) => UNIT_IN_RUB[baseUnit] / UNIT_IN_RUB[denomination]; + +// Колонки, которые всегда текстовые (ID/коды), независимо от того, что значение похоже на число. +// Перенесено из Cell.jsx — там теперь только импорт. +export const TEXT_COLUMN_IDS = new Set([ + 'data.header.num_group', + 'data.nomenclature_group_id', + 'data.header.item_id', + 'data.article_id', + 'data.code_razdela', + 'data.header.num_group_id', + 'data.section_no', + 'data.expense_item_code', +]); + +// Последний сегмент accessorKey (после последней точки), который никогда не денежный: +// количества, ставки, курсы, коды, идентификаторы и т.п. +const NON_MONEY_LAST_SEGMENTS = new Set([ + 'qty_q1', + 'qty_q2', + 'qty_q3', + 'qty_q4', + 'rented_area', + 'vat_rate', + 'exchange_rate', + 'amount_foreign', + 'section', + 'section_code', + 'section_no', + 'kod_razdela', + 'item_id', + 'num_group', + 'num_group_id', + 'internal_order', + 'contract_number', + 'vsp_id', + 'pay_year', + 'order', + 'expense_item_code', +]); + +// Лист "АХР лимиты" (FORM_2/AHR_LIMIT) — нормативы хранятся в рублях, а не в тыс. руб. +// У листа нет селектора разрядности (SettingPanel), значения показываются как есть. +export const AHR_LIMIT_SHEET = 'AHR_LIMIT'; +const AHR_LIMIT_RUB_LAST_SEGMENTS = new Set([ + 'limit_with_vat', + 'limit_without_vat', + 'amount_q1', + 'amount_q2', + 'amount_q3', + 'amount_q4', + 'amount_year', +]); + +const getLastSegment = (accessorKey) => { + if (typeof accessorKey !== 'string' || accessorKey === '') return ''; + return accessorKey.split('.').at(-1); +}; + +/** + * Денежная единица колонки в базе: 'thousand' | 'rub' | null (колонка не денежная). + * Порядок проверки: + * 1. явное поле columnDef.moneyUnit, если задано (в т.ч. null); + * 2. null для TEXT_COLUMN_IDS и для NON_MONEY_LAST_SEGMENTS; + * 3. 'rub' для нормативов листа AHR_LIMIT; + * 4. иначе 'thousand'. + * + * @param {{ accessorKey?: string, moneyUnit?: 'thousand'|'rub'|null }} columnDef + * @param {string} [sheetName] + * @returns {'thousand'|'rub'|null} + */ +export const getMoneyUnit = (columnDef, sheetName) => { + if (columnDef && Object.hasOwn(columnDef, 'moneyUnit')) { + return columnDef.moneyUnit; + } + + const accessorKey = columnDef?.accessorKey; + if (TEXT_COLUMN_IDS.has(accessorKey)) return null; + + const lastSegment = getLastSegment(accessorKey); + if (lastSegment.endsWith('_qty') || NON_MONEY_LAST_SEGMENTS.has(lastSegment)) return null; + + if (sheetName === AHR_LIMIT_SHEET && AHR_LIMIT_RUB_LAST_SEGMENTS.has(lastSegment)) return 'rub'; + + return 'thousand'; +}; diff --git a/web/src/components/RealtimeTable/utils/moneyUtils.test.js b/web/src/components/RealtimeTable/utils/moneyUtils.test.js new file mode 100644 index 0000000..169e51f --- /dev/null +++ b/web/src/components/RealtimeTable/utils/moneyUtils.test.js @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + AHR_LIMIT_SHEET, + DEFAULT_DENOMINATION, + TEXT_COLUMN_IDS, + UNIT_IN_RUB, + getDisplayFactor, + getMoneyUnit, + resolveDenomination, +} from './moneyUtils.js'; + +test('getDisplayFactor converts between units with thousand as the base', () => { + assert.equal(getDisplayFactor('thousand', 'thousand'), 1); + assert.equal(getDisplayFactor('thousand', 'million'), 0.001); + assert.equal(getDisplayFactor('thousand', 'rub'), 1000); + assert.equal(getDisplayFactor('rub', 'thousand'), 0.001); +}); + +test('getMoneyUnit defaults ordinary money columns to thousand', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.plan.q1' }, 'AHR'), 'thousand'); +}); + +test('getMoneyUnit returns null for quantities, rates, codes and ids regardless of sheet', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.qty_q1' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.contract.vat_rate' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.contract_detail.exchange_rate' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.rented_area' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.header.section_code' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.header.internal_order' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.header.num_group' }), null); +}); + +test('getMoneyUnit returns null for other *_qty columns via the suffix pattern', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.some_qty' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.header.amount_foreign' }), null); +}); + +test('getMoneyUnit returns null for TEXT_COLUMN_IDS not covered by the generic suffix list', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.article_id' }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.code_razdela' }), null); + assert.ok(TEXT_COLUMN_IDS.has('data.article_id')); +}); + +test('getMoneyUnit treats AHR_LIMIT limits and amounts as rub, not thousand', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.amount_q1' }, 'AHR_LIMIT'), 'rub'); + assert.equal(getMoneyUnit({ accessorKey: 'data.limit_without_vat' }, 'AHR_LIMIT'), 'rub'); + // тот же accessorKey на другом листе — обычная денежная колонка в тыс. руб. + assert.equal(getMoneyUnit({ accessorKey: 'data.amount_q1' }, 'OPER'), 'thousand'); +}); + +test('getMoneyUnit respects an explicit moneyUnit override, including null', () => { + assert.equal(getMoneyUnit({ accessorKey: 'data.plan.q1', moneyUnit: null }), null); + assert.equal(getMoneyUnit({ accessorKey: 'data.qty_q1', moneyUnit: 'rub' }), 'rub'); +}); + +test('resolveDenomination falls back to thousand when nothing is stored', () => { + assert.equal(resolveDenomination(null), DEFAULT_DENOMINATION); + assert.equal(resolveDenomination(undefined), 'thousand'); + assert.equal(resolveDenomination(''), 'thousand'); + assert.equal(resolveDenomination('rub'), 'rub'); +}); + +test('UNIT_IN_RUB defines the four denominations the selector supports', () => { + assert.deepEqual(Object.keys(UNIT_IN_RUB).sort(), ['billion', 'million', 'rub', 'thousand']); +}); + +test('AHR_LIMIT_SHEET names the sheet without a denomination selector', () => { + // Используется tableColumns.jsx/SettingPanel.jsx, чтобы отключить масштабирование и редактирование + // денежных колонок на листе, где значения всегда показываются как есть. + assert.equal(AHR_LIMIT_SHEET, 'AHR_LIMIT'); +}); diff --git a/web/src/components/common/DenominationSelect.jsx b/web/src/components/common/DenominationSelect.jsx index 0307252..024d52f 100644 --- a/web/src/components/common/DenominationSelect.jsx +++ b/web/src/components/common/DenominationSelect.jsx @@ -5,8 +5,8 @@ const DenominationSelect = ({ value, onChange, minWidth = 140 }) => ( ); diff --git a/web/src/components/common/useDenomination.js b/web/src/components/common/useDenomination.js index 72999e7..e048a20 100644 --- a/web/src/components/common/useDenomination.js +++ b/web/src/components/common/useDenomination.js @@ -1,16 +1,15 @@ import { useCallback, useEffect, useState } from 'react'; +import { resolveDenomination } from '../RealtimeTable/utils/moneyUtils'; const CHANGE_EVENT = 'denomination-change'; -const readValue = (key) => { - try { return localStorage.getItem(key) || 'rub'; } catch { return 'rub'; } -}; -export const getDenominationDivisor = (denomination) => ({ - rub: 1, - thousand: 1000, - million: 1000000, - billion: 1000000000, -}[denomination] || 1); +const readValue = (key) => { + try { + return resolveDenomination(localStorage.getItem(key)); + } catch { + return resolveDenomination(null); + } +}; export default function useDenomination(storageKey) { const [values, setValues] = useState({}); @@ -31,5 +30,5 @@ export default function useDenomination(storageKey) { return () => window.removeEventListener(CHANGE_EVENT, sync); }, [storageKey]); - return { denomination, setDenomination, divisor: getDenominationDivisor(denomination) }; + return { denomination, setDenomination }; } diff --git a/web/src/pages/ProjectPages/ProjectSummariesPage.jsx b/web/src/pages/ProjectPages/ProjectSummariesPage.jsx index 9e9ebc9..bfd6a96 100644 --- a/web/src/pages/ProjectPages/ProjectSummariesPage.jsx +++ b/web/src/pages/ProjectPages/ProjectSummariesPage.jsx @@ -45,6 +45,7 @@ const valueColumn = (id, header, getValue) => ({ size: 112, muiTableBodyCellProps: { align: 'right' }, muiTableHeadCellProps: { align: 'right' }, + meta: { money: true }, }); const headerColumns = [ { id: 'section_code', header: '№ п/п', accessorFn: (row) => row.data?.header?.section_code, size: 92 }, diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index eb76b8d..e4551f2 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -9,6 +9,7 @@ import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; import { PrimaryButton } from '../../components/common/Buttons/Buttons'; import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; +import { getDisplayFactor } from '../../components/RealtimeTable/utils/moneyUtils'; import { createFixedRowVirtualizerOptions, getRootFontSizePx } from '../../components/RealtimeTable/utils/virtualizerOptions'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; @@ -36,11 +37,11 @@ const COLUMN_BAND_COLORS = { const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data); -const formatValue = (value, divisor = 1) => { +const formatValue = (value, factor = 1) => { if (value === null || value === undefined || value === '') return '—'; const numericValue = typeof value === 'number' ? value : Number(value); if (numericValue === 0) return '-'; - return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue / divisor); + return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue * factor); }; const textColumn = (id, header, path, size = 10, options = {}) => ({ @@ -62,6 +63,7 @@ const numericColumn = (id, header, path, size = 7.5) => ({ enableColumnFilter: false, muiTableBodyCellProps: { align: 'right' }, muiTableHeadCellProps: { align: 'right' }, + meta: { money: true }, }); const MONTHS_BY_QUARTER = { @@ -346,11 +348,11 @@ const getColumnConfig = (sheet) => { return COLUMN_CONFIG_CACHE.get(sheet); }; -const scaleNumericColumns = (columns, divisor) => columns.map((column) => { - const isText = ['name', 'org_name', 'smeta_type', 'smeta_direction', 'section_code', 'contract_number'].includes(column.id); - const children = column.columns ? scaleNumericColumns(column.columns, divisor) : undefined; - if (isText || children) return { ...column, ...(children ? { columns: children } : {}) }; - return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), divisor) }; +const scaleNumericColumns = (columns, factor) => columns.map((column) => { + const children = column.columns ? scaleNumericColumns(column.columns, factor) : undefined; + if (children) return { ...column, columns: children }; + if (!column.meta?.money) return column; + return { ...column, Cell: ({ cell }) => formatValue(cell.getValue(), factor) }; }); const matchesSearch = (row, query) => { @@ -370,8 +372,9 @@ export default function Svod2Page() { const navigate = useNavigate(); const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); const [sheet, setSheet] = useState('GO'); - const denominationStorageKey = `svod2_${sheet}_denomination`; - const { denomination, setDenomination: handleDenominationChange, divisor } = useDenomination(denominationStorageKey); + const denominationStorageKey = `svod2_${sheet}_denomination_v2`; + const { denomination, setDenomination: handleDenominationChange } = useDenomination(denominationStorageKey); + const factor = useMemo(() => getDisplayFactor('thousand', denomination), [denomination]); const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState); const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []]))); const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0]))); @@ -463,7 +466,7 @@ export default function Svod2Page() { return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; }, [hasCurrentData, rows, search]); const { columns: rawColumns, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]); - const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]); + const columns = useMemo(() => scaleNumericColumns(rawColumns, factor), [rawColumns, factor]); const table = useMaterialReactTable({ columns, data: filteredRows, diff --git a/web/src/pages/SvodPage/SummaryTable.jsx b/web/src/pages/SvodPage/SummaryTable.jsx index fc2b68e..2704a5f 100644 --- a/web/src/pages/SvodPage/SummaryTable.jsx +++ b/web/src/pages/SvodPage/SummaryTable.jsx @@ -4,6 +4,7 @@ import DenominationSelect from '../../components/common/DenominationSelect'; import useDenomination from '../../components/common/useDenomination'; import { blueColumn, greenColumn, orangeColumn } from '../../components/RealtimeTable/constants/columnColors'; import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig'; +import { getDisplayFactor } from '../../components/RealtimeTable/utils/moneyUtils'; import { buildRowHierarchy } from '../../components/RealtimeTable/utils/rowUtils'; import { createFixedRowVirtualizerOptions, getRootFontSizePx } from '../../components/RealtimeTable/utils/virtualizerOptions'; @@ -60,31 +61,32 @@ export const filterSummaryTree = (tree, query) => export const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0); -const denominationKey = (tableKey) => `summary_${tableKey}_denomination`; +const denominationKey = (tableKey) => `summary_${tableKey}_denomination_v2`; -const scaleColumns = (columns, divisor) => columns.map((column) => { - const children = column.columns ? scaleColumns(column.columns, divisor) : undefined; +const scaleColumns = (columns, factor) => columns.map((column) => { + const children = column.columns ? scaleColumns(column.columns, factor) : undefined; const originalCell = column.Cell; - const isAmount = !['section_code', 'name', 'mrt-row-expand'].includes(column.id) && /plan|approved|fact|corrected|detail|total|quarter|residual|actual|adj|base|correction/i.test(column.id || ''); + const isMoney = Boolean(column.meta?.money); return { ...column, ...(children ? { columns: children } : {}), - ...(isAmount ? { Cell: ({ cell }) => { + ...(isMoney ? { Cell: ({ cell }) => { const value = cell.getValue(); if (value === null || value === undefined || value === '') return originalCell ? originalCell({ cell }) : '—'; const numeric = Number(value); if (!Number.isFinite(numeric)) return originalCell ? originalCell({ cell }) : value; if (numeric === 0) return '-'; - return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(numeric / divisor); + return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(numeric * factor); } } : {}), }; }); export const SummaryTable = memo(function SummaryTable({ columns, data, loading, colorBySource = false, tableKey = 'default' }) { const storageKey = denominationKey(tableKey); - const { divisor } = useDenomination(storageKey); + const { denomination } = useDenomination(storageKey); + const factor = getDisplayFactor('thousand', denomination); - const scaledColumns = useMemo(() => scaleColumns(columns, divisor), [columns, divisor]); + const scaledColumns = useMemo(() => scaleColumns(columns, factor), [columns, factor]); const table = useMaterialReactTable({ columns: scaledColumns, data, diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx index 3e8d10b..ce375f7 100644 --- a/web/src/pages/SvodPage/SvodPage.jsx +++ b/web/src/pages/SvodPage/SvodPage.jsx @@ -75,6 +75,7 @@ const valueColumn = (metric, direction, period) => ({ size: tableSize(period.key === 'year' ? 8 : 6.75), muiTableBodyCellProps: { align: 'right' }, muiTableHeadCellProps: { align: 'right' }, + meta: { money: true }, }); const createForm3MetricColumns = (metric) => ({ @@ -137,6 +138,7 @@ const createColumns = (sheet) => { size: tableSize(8.25), muiTableBodyCellProps: { align: 'right' }, muiTableHeadCellProps: { align: 'right' }, + meta: { money: true }, }, ], },