From 6c3cd5b2e04c9489ef0d5deab3465d333c341dd6 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 16 Oct 2026 14:41:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D0=BB=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B0=D0=B7=D1=80=D1=8F=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D0=B2=20=D1=82=D0=B2=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/ExportJobs.jsx | 6 +-- .../RealtimeTable/Cell/Cell/Cell.jsx | 9 ++-- .../RealtimeTable/RealtimeTable.jsx | 13 ++++- .../SettingPanel/SettingPanel.jsx | 4 ++ .../components/RealtimeTable/tableColumns.jsx | 6 ++- .../components/common/DenominationSelect.jsx | 14 +++++ .../ProjectPages/ProjectSummariesPage.jsx | 9 ++-- web/src/pages/Svod2Page/Svod2Page.jsx | 27 ++++++++-- web/src/pages/SvodPage/SummaryTable.jsx | 54 +++++++++++++++++-- web/src/pages/SvodPage/SvodPage.jsx | 6 +-- 10 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 web/src/components/common/DenominationSelect.jsx diff --git a/web/src/components/ExportJobs.jsx b/web/src/components/ExportJobs.jsx index 19645db..9baa92c 100644 --- a/web/src/components/ExportJobs.jsx +++ b/web/src/components/ExportJobs.jsx @@ -31,18 +31,18 @@ export default function ExportJobs() { if (stopped || wasHandled(job.id) || processing.has(job.id)) return; const toastId = `export:${user.id}:${job.id}`; if (['queued', 'processing'].includes(job.status)) { - showLoading(toastId, 'Идёт формирование Excel…'); + showLoading(toastId, 'Идёт формирование файла…'); return; } processing.add(job.id); - showLoading(toastId, 'Скачивание Excel…'); + showLoading(toastId, 'Скачивание файла…'); try { if (job.status === 'failed') throw new Error(job.error || 'Ошибка экспорта'); const response = await downloadExport(job.id); if (stopped) return; await downloadFile(response, getExportFilename(job), 'xlsx'); markHandled(job.id); - toast.update(toastId, { render: 'Excel сформирован, скачивание началось', type: 'success', isLoading: false, autoClose: 3000 }); + toast.update(toastId, { render: 'Файл сформирован, скачивание началось', type: 'success', isLoading: false, autoClose: 3000 }); } catch (error) { if (stopped) return; if (job.status === 'failed' || error?.response?.status === 410) markHandled(job.id); diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index e36703b..7ada266 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -137,7 +137,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) => }); }; -const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions }) => { +const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => { const cellKey = `${row.id}_${column.id}`; const isLocked = useCellLock(cellKey); @@ -149,7 +149,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(display, false); + display = formatNumber(Number(display) / displayDivisor, false); } if (isVspDropdown && value != null) { @@ -160,7 +160,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, } return { rawValue: value, displayValue: display, isNumeric: isNum }; - }, [cell, column.id, isVspDropdown, vspOptions]); + }, [cell, column.id, isVspDropdown, vspOptions, displayDivisor]); const highlightedContent = useMemo(() => { const searchQueries = [globalFilter, columnFilter]; @@ -220,7 +220,8 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => { prevProps.backgroundColor === nextProps.backgroundColor && prevProps.isInvalid === nextProps.isInvalid && prevProps.isEditable === nextProps.isEditable && - prevProps.vspOptions === nextProps.vspOptions + prevProps.vspOptions === nextProps.vspOptions && + prevProps.displayDivisor === nextProps.displayDivisor ); }); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index ea9c8b6..86fb636 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -30,6 +30,7 @@ import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; import { ROLES_NAME_ID } from '../../constants/constants'; import { FormsNoCanAddRow } from './constants/formConfig'; +import { loadFromStorage, saveToStorage } from './utils/localStorageUtils'; const CONFIG_CACHE = new Map(); @@ -161,6 +162,13 @@ 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 [denominationByTable, setDenominationByTable] = useState({}); + const denomination = denominationByTable[denominationStorageKey] ?? loadFromStorage(denominationStorageKey, 'rub'); + const setTableDenomination = useCallback((value) => { + saveToStorage(denominationStorageKey, value); + setDenominationByTable((current) => ({ ...current, [denominationStorageKey]: value })); + }, [denominationStorageKey]); useEffect(() => { rowVirtualizerRef.current?.measure?.(); @@ -261,12 +269,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onCellNumberClick: handleClickRowCell, onCellUpdateError: handleCellUpdateError, isCellInvalid, + denomination, }); } catch (error) { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid]); + }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]); // MRT запоминает индексы pinned-колонок до завершения асинхронной // загрузки конфигурации. Новая ссылка на columnPinning заставляет @@ -774,6 +783,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { columnVisibility={columnVisibility} columnsCurStage={columnsCurStage} onChangeSizeMult={setSizeMult} + denomination={denomination} + onChangeDenomination={setTableDenomination} onGlobalFilterChange={handleGlobalFilterChange} globalFilter={globalFilter} onSearchKeyDown={handleSearchKeyDown} diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 424a86e..3acceb7 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -6,6 +6,7 @@ import { stageColumnsHiddenFromPicker } from '../../Stages/constants'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import CollapsibleTree from '../../common/CollapsibleTree'; import SearchComponent from '../../common/SearchComponent'; +import DenominationSelect from '../../common/DenominationSelect'; import { AddLine, AllStageColumns, @@ -97,6 +98,8 @@ const SettingPanel = ({ onNavigateToColumn, expenseItemsVisibilityAction, onToggleDepthVisibility, + denomination, + onChangeDenomination, }) => { const [isExporting, setIsExporting] = useState(false); const isPinned = useMemo( @@ -176,6 +179,7 @@ const SettingPanel = ({ + diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index caf5a9e..d7c6c23 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -118,7 +118,9 @@ export const getTableColumns = ({ onCellUpdateError, onCellNumberClick, isCellInvalid, + denomination = 'rub', }) => { + const displayDivisor = denomination === 'billion' ? 1000000000 : denomination === 'million' ? 1000000 : denomination === 'thousand' ? 1000 : 1; const columnColors = { ...columnsConfig.colors }; const columns = structuredClone(columnsConfig.columns); @@ -153,6 +155,7 @@ export const getTableColumns = ({ cached.globalFilter === currentGlobalFilter && cached.columnFilter === currentColumnFilter && cached.value === value && + cached.displayDivisor === displayDivisor && cached.isInvalid === isInvalid && cached.backgroundColor === backgroundColor ) { @@ -170,7 +173,8 @@ export const getTableColumns = ({ backgroundColor, isInvalid, value, - _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}`, + displayDivisor, + _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`, }; rowCache[key] = props; diff --git a/web/src/components/common/DenominationSelect.jsx b/web/src/components/common/DenominationSelect.jsx new file mode 100644 index 0000000..0307252 --- /dev/null +++ b/web/src/components/common/DenominationSelect.jsx @@ -0,0 +1,14 @@ +import { FormControl, MenuItem, Select } from '@mui/material'; + +const DenominationSelect = ({ value, onChange, minWidth = 140 }) => ( + + + +); + +export default DenominationSelect; diff --git a/web/src/pages/ProjectPages/ProjectSummariesPage.jsx b/web/src/pages/ProjectPages/ProjectSummariesPage.jsx index 6c2ffee..9e9ebc9 100644 --- a/web/src/pages/ProjectPages/ProjectSummariesPage.jsx +++ b/web/src/pages/ProjectPages/ProjectSummariesPage.jsx @@ -6,7 +6,7 @@ import { ProjectsApi } from '../../api/projects'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; import { PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons'; import { PROJECT_TYPES } from '../../constants/constants'; -import { SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from '../SvodPage/SummaryTable'; +import { SummaryDenominationControl, SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from '../SvodPage/SummaryTable'; import { SvodFilters, useSummaryOrganizations } from '../SvodPage/SvodFilters'; const SHEETS = [ @@ -234,12 +234,13 @@ export default function ProjectSummariesPage() { onChange={updateFilters} onReset={() => setProjectType('')} searchPlaceholder='Код или статья расходов' - additionalFilters={ + additionalFilters={<> setProjectType(event.target.value)} sx={{ minWidth: '13rem' }}> Все типы {PROJECT_TYPES.map((type) => {type})} - } + + } > {!loading && filteredCount !== rows.length && ( @@ -254,7 +255,7 @@ export default function ProjectSummariesPage() { {error ? ( Повторить}>{error} ) : ( - + )} ); diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index c359714..ea3ac2e 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; import { PrimaryButton } from '../../components/common/Buttons/Buttons'; +import DenominationSelect from '../../components/common/DenominationSelect'; import { useSvod } from '../SvodPage/SvodContext'; import { SvodFilters } from '../SvodPage/SvodFilters'; @@ -32,11 +33,11 @@ const COLUMN_BAND_COLORS = { const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data); -const formatValue = (value) => { +const formatValue = (value, divisor = 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); + return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue / divisor); }; const textColumn = (id, header, path, size = 10, options = {}) => ({ @@ -342,6 +343,13 @@ 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 matchesSearch = (row, query) => { const searchableData = { ...row.data?.header, @@ -359,6 +367,15 @@ export default function Svod2Page() { const navigate = useNavigate(); const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); const [sheet, setSheet] = useState('GO'); + const [denominations, setDenominations] = useState({}); + const denominationStorageKey = `svod2_${sheet}_denomination`; + const denomination = denominations[denominationStorageKey] ?? (() => { try { return localStorage.getItem(denominationStorageKey) || 'rub'; } catch { return 'rub'; } })(); + const divisor = denomination === 'billion' ? 1000000000 : denomination === 'million' ? 1000000 : denomination === 'thousand' ? 1000 : 1; + + const handleDenominationChange = (value) => { + setDenominations((current) => ({ ...current, [denominationStorageKey]: value })); + try { localStorage.setItem(denominationStorageKey, value); } catch { /* storage may be unavailable */ } + }; 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]))); @@ -449,7 +466,8 @@ export default function Svod2Page() { const normalizedSearch = search.trim().toLowerCase(); return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; }, [hasCurrentData, rows, search]); - const { columns, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]); + const { columns: rawColumns, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]); + const columns = useMemo(() => scaleNumericColumns(rawColumns, divisor), [rawColumns, divisor]); const table = useMaterialReactTable({ columns, data: filteredRows, @@ -590,7 +608,8 @@ export default function Svod2Page() { onChange={updateCurrentSheetFilters} searchPlaceholder='ССП/РФ, код, статья или договор' emptyOrganizationsPlaceholder='Выберите ССП/РФ' - allowSelectAll> + allowSelectAll + additionalFilters={}> {hasCurrentData && !loading && filteredRows.length !== rows.length && ( {`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`} diff --git a/web/src/pages/SvodPage/SummaryTable.jsx b/web/src/pages/SvodPage/SummaryTable.jsx index 3d048d4..9c9d66c 100644 --- a/web/src/pages/SvodPage/SummaryTable.jsx +++ b/web/src/pages/SvodPage/SummaryTable.jsx @@ -1,5 +1,6 @@ import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; -import { memo } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; +import DenominationSelect from '../../components/common/DenominationSelect'; import { blueColumn, greenColumn, orangeColumn } from '../../components/RealtimeTable/constants/columnColors'; import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig'; import { buildRowHierarchy } from '../../components/RealtimeTable/utils/rowUtils'; @@ -55,9 +56,45 @@ export const filterSummaryTree = (tree, query) => export const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0); -export const SummaryTable = memo(function SummaryTable({ columns, data, loading, colorBySource = false }) { +const denominationKey = (tableKey) => `summary_${tableKey}_denomination`; +const loadDenomination = (key) => { + try { return localStorage.getItem(key) || 'rub'; } catch { return 'rub'; } +}; + +const scaleColumns = (columns, divisor) => columns.map((column) => { + const children = column.columns ? scaleColumns(column.columns, divisor) : 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 || ''); + return { + ...column, + ...(children ? { columns: children } : {}), + ...(isAmount ? { 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); + } } : {}), + }; +}); + +export const SummaryTable = memo(function SummaryTable({ columns, data, loading, colorBySource = false, tableKey = 'default' }) { + const [denominations, setDenominations] = useState({}); + const storageKey = denominationKey(tableKey); + const denomination = denominations[storageKey] ?? loadDenomination(storageKey); + useEffect(() => { + const sync = (event) => { + if (event.detail?.key === storageKey) setDenominations((current) => ({ ...current, [storageKey]: event.detail.value })); + }; + window.addEventListener('summary-denomination-change', sync); + return () => window.removeEventListener('summary-denomination-change', sync); + }, [storageKey]); + const divisor = denomination === 'billion' ? 1000000000 : denomination === 'million' ? 1000000 : denomination === 'thousand' ? 1000 : 1; + + const scaledColumns = useMemo(() => scaleColumns(columns, divisor), [columns, divisor]); const table = useMaterialReactTable({ - columns, + columns: scaledColumns, data, enableExpanding: true, enableExpandAll: true, @@ -152,3 +189,14 @@ export const SummaryTable = memo(function SummaryTable({ columns, data, loading, return ; }); + +export function SummaryDenominationControl({ tableKey = 'default' }) { + const storageKey = denominationKey(tableKey); + const [values, setValues] = useState({}); + const value = values[storageKey] ?? loadDenomination(storageKey); + return { + setValues((current) => ({ ...current, [storageKey]: next })); + try { localStorage.setItem(storageKey, next); } catch { /* storage may be unavailable */ } + window.dispatchEvent(new CustomEvent('summary-denomination-change', { detail: { key: storageKey, value: next } })); + }} />; +} diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx index e323eec..3e8d10b 100644 --- a/web/src/pages/SvodPage/SvodPage.jsx +++ b/web/src/pages/SvodPage/SvodPage.jsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons'; -import { SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from './SummaryTable'; +import { SummaryDenominationControl, SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from './SummaryTable'; import { useSvod } from './SvodContext'; import { SvodFilters } from './SvodFilters'; @@ -264,7 +264,7 @@ export default function SvodPage() { ))} - + }> {!loading && filteredRowsCount !== rows.length && ( {`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`} @@ -301,7 +301,7 @@ export default function SvodPage() { {error} ) : ( - + )} ); -- 2.47.2