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) => )}
- }
+
+ >}
>
{!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}
) : (
-
+
)}
);