From c034e384c7718f14184994cb77cbb48e435e75db Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Mon, 31 Aug 2026 17:42:38 +0300 Subject: [PATCH 1/2] =?UTF-8?q?svod:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/api/summary.js | 4 +- web/src/app/Routes.jsx | 23 +- .../components/HeaderMenu/HeaderSwitch.jsx | 8 +- web/src/pages/Svod2Page/Svod2Page.jsx | 570 ++++++++++++++++++ web/src/pages/SvodPage/SvodContext.jsx | 53 +- web/src/pages/SvodPage/SvodFilters.jsx | 131 ++++ web/src/pages/SvodPage/SvodPage.jsx | 235 ++++---- .../SvodsNavigatorPage/SvodsNavigatorPage.jsx | 139 +++++ 8 files changed, 1014 insertions(+), 149 deletions(-) create mode 100644 web/src/pages/Svod2Page/Svod2Page.jsx create mode 100644 web/src/pages/SvodPage/SvodFilters.jsx create mode 100644 web/src/pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx diff --git a/web/src/api/summary.js b/web/src/api/summary.js index b4d27f0..6791b04 100644 --- a/web/src/api/summary.js +++ b/web/src/api/summary.js @@ -1,8 +1,8 @@ import api from './client'; export const SummaryApi = { - get(sheet, year, orgIds = []) { + get(summaryNumber, sheet, year, orgIds = []) { const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {}; - return api.get(`/svod/1/${sheet}/${year}`, { params }).then((response) => response.data); + return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data); }, }; diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx index 43808a4..94e8399 100644 --- a/web/src/app/Routes.jsx +++ b/web/src/app/Routes.jsx @@ -10,9 +10,10 @@ import LoginPage from '../pages/LoginPage'; import NewFormTablePage from '../pages/NewFormTablePage.jsx'; import NewTablePage from '../pages/NewTablePage.jsx'; import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx'; -import ProjectsPage from '../pages/ProjectsPage/ProjectsPage.jsx'; -import SvodPage from '../pages/SvodPage/SvodPage.jsx'; +import Svod2Page from '../pages/Svod2Page/Svod2Page.jsx'; import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx'; +import SvodPage from '../pages/SvodPage/SvodPage.jsx'; +import SvodsNavigatorPage from '../pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx'; import TablePage from '../pages/TablePage'; import TableTempPage from '../pages/TableTempPage/TableTempPage'; import TablesTest from '../pages/TablesTest.jsx'; @@ -56,7 +57,23 @@ export const AppRoutes = () => { } /> } /> } /> - } /> + } /> + + + + } + /> + + + + } + /> diff --git a/web/src/components/HeaderMenu/HeaderSwitch.jsx b/web/src/components/HeaderMenu/HeaderSwitch.jsx index a2c7e09..7a29ae1 100644 --- a/web/src/components/HeaderMenu/HeaderSwitch.jsx +++ b/web/src/components/HeaderMenu/HeaderSwitch.jsx @@ -54,7 +54,7 @@ export function HeaderSwitch() { const formsTasksPaths = ['/tasks', '/forms']; const adminPathsPrefix = '/admin_panel'; const dictPaths = '/dicts-navigator'; - const svodPaths = '/svod'; + const svodPaths = ['/svod', '/svods-navigator']; const projectsPaths = '/projects'; if (!user) return; @@ -75,9 +75,9 @@ export function HeaderSwitch() { Проекты - + location.pathname.startsWith(path)) ? 'active' : ''}>
- + location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} />
Своды
@@ -103,4 +103,4 @@ export function HeaderSwitch() { if (targetElement) { return createPortal(portalContent, targetElement); } -} \ No newline at end of file +} diff --git a/web/src/pages/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx new file mode 100644 index 0000000..718037b --- /dev/null +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -0,0 +1,570 @@ +import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; +import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; +import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; +import { MRT_Localization_RU } from 'material-react-table/locales/ru'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; +import { PrimaryButton } from '../../components/common/Buttons/Buttons'; +import { useSvod } from '../SvodPage/SvodContext'; +import { SvodFilters } from '../SvodPage/SvodFilters'; + +const SHEETS = [ + { value: 'GO', label: 'ССП' }, + { value: 'RF', label: 'РФ' }, +]; +const QUARTERS = [1, 2, 3, 4]; +const tableSize = (rem) => rem * 16; +const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }); +const ROW_HEIGHT = 37; +const ROW_VIRTUALIZER_OPTIONS = { + overscan: 8, + estimateSize: () => ROW_HEIGHT, + measureElement: (element) => element?.offsetHeight || ROW_HEIGHT, +}; +const COLUMN_BAND_COLORS = { + head: ['#f8f9fa', '#f1f7f3'], + body: ['#ffffff', '#f8fbf9'], +}; + +const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data); + +const formatValue = (value) => { + if (value === null || value === undefined || value === '') return '—'; + const numericValue = typeof value === 'number' ? value : Number(value); + return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue); +}; + +const textColumn = (id, header, path, size = 10, options = {}) => ({ + id, + header, + accessorFn: (row) => valueAt(row, path), + Cell: ({ cell }) => cell.getValue() || '—', + size: tableSize(size), + enableColumnFilter: false, + ...options, +}); + +const numericColumn = (id, header, path, size = 7.5) => ({ + id, + header, + accessorFn: (row) => valueAt(row, path), + Cell: ({ cell }) => formatValue(cell.getValue()), + size: tableSize(size), + enableColumnFilter: false, + muiTableBodyCellProps: { align: 'right' }, + muiTableHeadCellProps: { align: 'right' }, +}); + +const MONTHS_BY_QUARTER = { + 1: ['Январь', 'Февраль', 'Март'], + 2: ['Апрель', 'Май', 'Июнь'], + 3: ['Июль', 'Август', 'Сентябрь'], + 4: ['Октябрь', 'Ноябрь', 'Декабрь'], +}; +const CARRYOVER_HEADERS = { + 1: 'Корректировка из 1 квартала во 2–4 кварталы', + 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`, + header: 'Корректировка внутри квартала', + columns: [ + numericColumn(`q${quarter}.adj_current`, 'Текущие корректировки (= 0)', [`q${quarter}`, 'adj_current'], 12), + numericColumn(`q${quarter}.adj_ssp`, 'Корректировки с ССП / сметой развития', [`q${quarter}`, 'adj_ssp'], 15), + numericColumn(`q${quarter}.adj_rf`, 'Корректировки с РФ', [`q${quarter}`, 'adj_rf'], 11), + numericColumn(`q${quarter}.adj_reserve`, 'Корректировки из резерва', [`q${quarter}`, 'adj_reserve'], 12), + ], +}); + +const createPaymentColumns = (quarter) => ({ + id: `q${quarter}.payment`, + header: 'Платеж и предоставление акта', + columns: [ + textColumn(`q${quarter}.payment_date`, 'Дата платежа', [`q${quarter}`, 'payment_date'], 9), + numericColumn(`q${quarter}.payment_amount`, 'Сумма, тыс. руб. (без НДС)', [`q${quarter}`, 'payment_amount'], 12), + numericColumn(`q${quarter}.payment_amount_ho`, 'в т.ч. сумма (без НДС) ГО', [`q${quarter}`, 'payment_amount_ho'], 12), + numericColumn(`q${quarter}.payment_amount_rf`, 'в т.ч. сумма (без НДС) РФ', [`q${quarter}`, 'payment_amount_rf'], 12), + textColumn(`q${quarter}.payment_act`, 'Предоставление акта (др. подтверждения)', [`q${quarter}`, 'payment_act'], 16), + ], +}); + +const createActualColumns = (quarter) => ({ + id: `q${quarter}.actual`, + header: 'Фактические расходы за квартал', + columns: [ + numericColumn(`q${quarter}.booking`, `Бронь ${quarter} кв.`, [`q${quarter}`, 'booking_amount'], 9), + ...MONTHS_BY_QUARTER[quarter].map((month, index) => + numericColumn(`q${quarter}.actual_m${index + 1}`, `Факт ${month}`, [`q${quarter}`, `actual_m${index + 1}`], 9), + ), + ...(quarter === 4 ? [numericColumn('q4.actual_spod', 'Факт СПОД', ['q4', 'actual_spod'], 9)] : []), + ], +}); + +const createCarryoverColumns = (quarter) => { + const nextQuarter = quarter + 1; + const transferColumns = QUARTERS.filter((targetQuarter) => targetQuarter > quarter).map((targetQuarter) => + numericColumn( + `q${quarter}.transfer_to_q${targetQuarter}`, + `Перенос в ${targetQuarter} кв.`, + [`q${quarter}`, `transfer_to_q${targetQuarter}`], + 9, + ), + ); + return { + id: `q${quarter}.carryover`, + header: CARRYOVER_HEADERS[quarter], + columns: [ + ...transferColumns, + numericColumn(`q${quarter}.transfer_to_economy`, 'Перенос в фонд экономии', [`q${quarter}`, 'transfer_to_economy'], 12), + numericColumn( + `q${nextQuarter}.plan_revision_eco_change`, + 'Изменение целевого назначения перенесенной экономии (= 0)', + [`q${nextQuarter}`, 'plan_revision_eco_change'], + 16, + ), + numericColumn( + `q${nextQuarter}.plan_revision_item_adj`, + 'Корректировка статей базового плана (= 0)', + [`q${nextQuarter}`, 'plan_revision_item_adj'], + 15, + ), + numericColumn( + `q${nextQuarter}.plan_revision_increase`, + 'Увеличение базового плана (> 0)', + [`q${nextQuarter}`, 'plan_revision_increase'], + 13, + ), + numericColumn( + `q${nextQuarter}.plan_revision_sequester`, + 'Секвестр базового плана (< 0)', + [`q${nextQuarter}`, 'plan_revision_sequester'], + 13, + ), + ], + }; +}; + +const createColumns = (sheet) => [ + textColumn('smeta_type', 'Вид сметы', ['header', 'smeta_type'], 8, { + enableColumnFilter: true, + filterVariant: 'select', + Cell: ({ cell }) => { + const value = cell.getValue(); + if (!value) return '—'; + const colors = ESTIMATE_TYPE_STYLES[value] || { + backgroundColor: '#f3f4f6', + borderColor: '#d1d5db', + color: '#4b5563', + }; + return ( + + {value} + + ); + }, + }), + textColumn('smeta_direction', 'Направление', ['header', 'smeta_direction'], 10, { enableColumnFilter: true, filterVariant: 'select' }), + textColumn('org_name', 'ССП', ['header', 'org_name'], 14, { + enableColumnFilter: true, + filterVariant: 'select', + Cell: ({ cell }) => { + const value = cell.getValue(); + if (!value) return '—'; + return ( + + {value} + + ); + }, + }), + textColumn('name', 'Наименование статьи', ['header', 'name'], 20), + + { + id: 'estimate', + header: sheet === 'GO' ? 'Смета расходов ГО в разрезе договоров' : 'Смета расходов РФ в разрезе договоров', + columns: [ + textColumn('item_id', 'ID статьи', ['header', 'item_id'], 7), + textColumn('num_group', 'ID группы номенклатуры', ['header', 'num_group'], 10), + textColumn('justification', 'Конкретный вид расхода', ['header', 'justification'], 18), + ...QUARTERS.map((quarter) => numericColumn(`plan.q${quarter}`, `${quarter} кв.`, ['plan', `plan_q${quarter}`])), + ], + }, + { + id: 'allocation', + header: 'Аллокация расходов', + columns: [ + textColumn('allocation.internal_order', 'Внутренний заказ', ['allocation', 'internal_order'], 12), + textColumn('allocation.property_object', 'Объект недвижимости', ['allocation', 'property_object'], 14), + ], + }, + { + id: 'sequestration', + header: 'Секвестирование ДФиП', + columns: QUARTERS.map((quarter) => + numericColumn(`sequestration.q${quarter}`, `${quarter} кв.`, ['sequestration', 'DFIP', `adj_q${quarter}`]), + ), + }, + { + id: 'reserve', + header: 'Отнесение в резерв', + columns: QUARTERS.map((quarter) => numericColumn(`reserve.q${quarter}`, `${quarter} кв.`, ['reserve', `amount_q${quarter}`])), + }, + { + id: 'approved', + header: 'Итоговая смета расходов', + columns: QUARTERS.map((quarter) => numericColumn(`approved.q${quarter}`, `${quarter} кв.`, ['approved', `approved_q${quarter}`])), + }, + { + id: 'contract', + header: 'Договор', + columns: [ + textColumn('contract.counterparty', 'Контрагент', ['contract_detail', 'counterparty'], 14), + textColumn('contract.reference', 'Договор (реквизиты)', ['contract_detail', 'reference'], 12), + textColumn('contract.subject', 'Предмет', ['contract_detail', 'subject'], 18), + textColumn('contract.currency', 'Валюта договора', ['contract_detail', 'currency'], 9), + numericColumn('contract.ceiling', 'Предельная ст-ть, тыс. руб. (без НДС)', ['contract_detail', 'ceiling_amount'], 14), + ...QUARTERS.map((quarter) => + numericColumn( + `contract.expenses_q${quarter}`, + `Расходы ${quarter} кв., тыс. руб. (без НДС)`, + ['contract_detail', `expenses_q${quarter}`], + 13, + ), + ), + textColumn('contract.vat_rate', 'Ставка НДС (%)', ['contract_detail', 'vat_rate'], 9), + textColumn('contract.deadline', 'Срок', ['contract_detail', 'deadline'], 9), + ], + }, + createCorrectionColumns(1), + createPaymentColumns(1), + createActualColumns(1), + createCarryoverColumns(1), + createCorrectionColumns(2), + createPaymentColumns(2), + createActualColumns(2), + createCarryoverColumns(2), + createCorrectionColumns(3), + createPaymentColumns(3), + createActualColumns(3), + createCarryoverColumns(3), + createCorrectionColumns(4), + createPaymentColumns(4), + 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; + return { + ...column, + enableColumnFilter: false, + columns: disableGroupColumnFilters(column.columns), + }; + }); + +const matchesSearch = (row, query) => { + const searchableData = { + ...row.data?.header, + ...row.data?.contract_summary, + ...row.data?.contract_detail, + }; + return Object.values(searchableData).some((value) => + String(value ?? '') + .toLowerCase() + .includes(query), + ); +}; + +export default function Svod2Page() { + const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); + const [sheet, setSheet] = useState('GO'); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [loadedQueryKey, setLoadedQueryKey] = useState(''); + const latestSummaryRequestRef = useRef(0); + const { year, selectedOrganizations, search } = getSheetFilters(sheet); + const selectedOrganizationIds = useMemo( + () => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)), + [selectedOrganizations], + ); + const currentQueryKey = useMemo( + () => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]), + [sheet, year, selectedOrganizations], + ); + const hasCurrentData = loadedQueryKey === currentQueryKey; + const updateCurrentSheetFilters = useCallback((changes) => updateSheetFilters(sheet, changes), [sheet, updateSheetFilters]); + + const loadSummary = useCallback(async () => { + const requestId = latestSummaryRequestRef.current + 1; + latestSummaryRequestRef.current = requestId; + setLoading(true); + setError(''); + try { + const response = await getSummary(sheet, year, selectedOrganizationIds); + if (latestSummaryRequestRef.current !== requestId) return; + setRows(response.result || []); + setLoadedQueryKey(currentQueryKey); + } catch (loadError) { + if (latestSummaryRequestRef.current !== requestId) return; + setRows([]); + setError(loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.'); + } finally { + if (latestSummaryRequestRef.current === requestId) setLoading(false); + } + }, [currentQueryKey, getSummary, selectedOrganizationIds, sheet, year]); + + useEffect(() => { + return () => { + latestSummaryRequestRef.current += 1; + }; + }, []); + + const refreshSummary = useCallback(() => { + invalidateSummary(sheet, year, selectedOrganizationIds); + loadSummary(); + }, [invalidateSummary, loadSummary, selectedOrganizationIds, sheet, year]); + + const filteredRows = useMemo(() => { + if (!hasCurrentData) return []; + const normalizedSearch = search.trim().toLowerCase(); + return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows; + }, [hasCurrentData, rows, search]); + const columns = useMemo(() => disableGroupColumnFilters(createColumns(sheet)), [sheet]); + const columnBands = useMemo(() => createColumnBandMap(columns), [columns]); + const columnGroupEnds = useMemo(() => createColumnGroupEndSet(columns), [columns]); + const table = useMaterialReactTable({ + columns, + data: filteredRows, + getRowId: (row) => `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`, + enableColumnActions: false, + enableColumnFilters: true, + enableFacetedValues: true, + enableDensityToggle: false, + enableFullScreenToggle: false, + enableHiding: false, + enablePagination: false, + enableRowVirtualization: true, + enableTopToolbar: false, + enableBottomToolbar: false, + enableSorting: false, + rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, + state: { isLoading: loading }, + initialState: { + columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] }, + showColumnFilters: true, + }, + muiTablePaperProps: { + elevation: 0, + sx: { + border: '0.0625rem solid #e5e7eb', + borderRadius: '0.75rem', + overflow: 'hidden', + flex: 1, + minHeight: 0, + width: '100%', + display: 'flex', + flexDirection: 'column', + }, + }, + muiTableContainerProps: { + sx: { + flex: 1, + overflow: 'auto', + minHeight: 0, + height: '100%', + '& thead': { position: 'sticky', top: 0, zIndex: 10 }, + }, + }, + muiTableHeadProps: { + sx: { + '& tr:not(:last-of-type) > th > .MuiCollapse-root': { + display: 'none', + }, + }, + }, + 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, + }, + }), + 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, + }, + }), + localization: { + ...MRT_Localization_RU, + noRecordsToDisplay: 'Нет данных для выбранных параметров', + }, + }); + let content = ; + if (!hasCurrentData && !loading && !error) { + content = ( + + {selectedOrganizations.length === 0 + ? 'Выберите одно или несколько ССП/РФ либо пункт «Все ССП/РФ», затем нажмите «Загрузить».' + : 'Нажмите «Загрузить», чтобы получить данные по выбранным параметрам.'} + + ); + } else if (error) { + content = ( + + Повторить + + }> + {error} + + ); + } + + return ( + + + + { + setSheet(value); + setError(''); + }} + sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}> + {SHEETS.map((item) => ( + + ))} + + + {hasCurrentData && !loading && filteredRows.length !== rows.length && ( + + {`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`} + + )} + {hasCurrentData ? ( + + ) : ( + + Загрузить + + )} + + + {content} + + ); +} diff --git a/web/src/pages/SvodPage/SvodContext.jsx b/web/src/pages/SvodPage/SvodContext.jsx index 7c8fd99..3a39e86 100644 --- a/web/src/pages/SvodPage/SvodContext.jsx +++ b/web/src/pages/SvodPage/SvodContext.jsx @@ -3,7 +3,7 @@ import { SummaryApi } from '../../api/summary'; const SvodContext = createContext(null); -const SHEET_VALUES = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4']; +const DEFAULT_SHEET_VALUES = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4']; const createDefaultFilters = () => ({ year: new Date().getFullYear(), @@ -11,7 +11,7 @@ const createDefaultFilters = () => ({ search: '', }); -const createInitialFilters = () => Object.fromEntries(SHEET_VALUES.map((sheet) => [sheet, createDefaultFilters()])); +const createInitialFilters = (sheetValues) => Object.fromEntries(sheetValues.map((sheet) => [sheet, createDefaultFilters()])); const createCacheKey = (year, organizationIds) => JSON.stringify([year, organizationIds.map(String).sort()]); @@ -20,8 +20,8 @@ const getSheetCache = (cache, sheet) => { return cache.get(sheet); }; -export const SvodProvider = ({ children }) => { - const [filtersBySheet, setFiltersBySheet] = useState(createInitialFilters); +export const SvodProvider = ({ children, summaryNumber = 1, sheetValues = DEFAULT_SHEET_VALUES }) => { + const [filtersBySheet, setFiltersBySheet] = useState(() => createInitialFilters(sheetValues)); const summaryCacheRef = useRef(new Map()); useEffect( @@ -43,29 +43,32 @@ export const SvodProvider = ({ children }) => { })); }, []); - const getSummary = useCallback((sheet, year, organizationIds = []) => { - const sheetCache = getSheetCache(summaryCacheRef.current, sheet); - const cacheKey = createCacheKey(year, organizationIds); - const cachedRequest = sheetCache.get(cacheKey); - if (cachedRequest) return cachedRequest; + const getSummary = useCallback( + (sheet, year, organizationIds = []) => { + const sheetCache = getSheetCache(summaryCacheRef.current, sheet); + const cacheKey = createCacheKey(year, organizationIds); + const cachedRequest = sheetCache.get(cacheKey); + if (cachedRequest) return cachedRequest; - const request = SummaryApi.get(sheet, year, organizationIds) - .then((response) => { - if (sheetCache.get(cacheKey) === request) { - sheetCache.set(cacheKey, Promise.resolve(response)); - } - return response; - }) - .catch((error) => { - if (sheetCache.get(cacheKey) === request) { - sheetCache.delete(cacheKey); - } - throw error; - }); + const request = SummaryApi.get(summaryNumber, sheet, year, organizationIds) + .then((response) => { + if (sheetCache.get(cacheKey) === request) { + sheetCache.set(cacheKey, Promise.resolve(response)); + } + return response; + }) + .catch((error) => { + if (sheetCache.get(cacheKey) === request) { + sheetCache.delete(cacheKey); + } + throw error; + }); - sheetCache.set(cacheKey, request); - return request; - }, []); + sheetCache.set(cacheKey, request); + return request; + }, + [summaryNumber], + ); const invalidateSummary = useCallback((sheet, year, organizationIds = []) => { summaryCacheRef.current.get(sheet)?.delete(createCacheKey(year, organizationIds)); diff --git a/web/src/pages/SvodPage/SvodFilters.jsx b/web/src/pages/SvodPage/SvodFilters.jsx new file mode 100644 index 0000000..35ce326 --- /dev/null +++ b/web/src/pages/SvodPage/SvodFilters.jsx @@ -0,0 +1,131 @@ +import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; +import { Autocomplete, Box, Chip, CircularProgress, InputAdornment, MenuItem, TextField } from '@mui/material'; +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; + +import { SspApi } from '../../api/ssp'; +import { useAuth } from '../../app/context/AuthProvider'; +import { ROLES_NAME_ID } from '../../constants/constants'; + +const SELECT_ALL_OPTION = { id: '__all__', title: 'Все ССП/РФ' }; + +export const getAvailableYears = () => { + const currentYear = new Date().getFullYear(); + return Array.from({ length: 7 }, (_, index) => currentYear + 1 - index); +}; + +export const useSummaryOrganizations = () => { + const { user } = useAuth(); + const [organizations, setOrganizations] = useState([]); + const [organizationsLoading, setOrganizationsLoading] = useState(false); + + useEffect(() => { + if (!user) return; + const loadOrganizations = async () => { + setOrganizationsLoading(true); + try { + if (user.role_id === ROLES_NAME_ID.admin) { + const response = await SspApi.getAll({ is_active: true, limit: 100 }); + setOrganizations(response.result || []); + } else { + setOrganizations(user.org_units || []); + } + } catch (_error) { + setOrganizations(user.org_units || []); + toast.error('Не удалось загрузить список ССП/РФ'); + } finally { + setOrganizationsLoading(false); + } + }; + loadOrganizations(); + }, [user]); + + return { organizations, organizationsLoading }; +}; + +export function SvodFilters({ + year, + selectedOrganizations, + search, + onChange, + searchPlaceholder = 'Код или статья расходов', + emptyOrganizationsPlaceholder = 'Все доступные', + allowSelectAll = false, + children, +}) { + const { organizations, organizationsLoading } = useSummaryOrganizations(); + const organizationOptions = allowSelectAll ? [SELECT_ALL_OPTION, ...organizations] : organizations; + + return ( + + onChange({ year: Number(event.target.value) })} + sx={{ width: '7.5rem' }}> + {getAvailableYears().map((availableYear) => ( + + {availableYear} + + ))} + + option.id === value.id} + getOptionLabel={(option) => option.title || ''} + onChange={(_event, value) => { + const selectedAll = value.some(({ id }) => id === SELECT_ALL_OPTION.id); + onChange({ selectedOrganizations: selectedAll ? [SELECT_ALL_OPTION] : value }); + }} + sx={{ minWidth: '18.75rem', flex: '1 1 22.5rem', maxWidth: '32.5rem' }} + renderTags={(value, getTagProps) => + value + .slice(0, 2) + .map((option, index) => { + const tagProps = getTagProps({ index }); + return ; + }) + .concat(value.length > 2 ? [] : []) + } + renderInput={(params) => ( + + {organizationsLoading && } + {params.InputProps?.endAdornment} + + ), + }} + /> + )} + /> + onChange({ search: event.target.value })} + placeholder={searchPlaceholder} + sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + {children} + + ); +} diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx index 0243125..7eb62c0 100644 --- a/web/src/pages/SvodPage/SvodPage.jsx +++ b/web/src/pages/SvodPage/SvodPage.jsx @@ -1,23 +1,16 @@ import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded'; -import SearchRoundedIcon from '@mui/icons-material/SearchRounded'; -import { - Alert, Autocomplete, Box, Button, Chip, CircularProgress, InputAdornment, - MenuItem, Paper, Tab, Tabs, TextField, Typography, -} from '@mui/material'; +import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { toast } from 'react-toastify'; -import { SspApi } from '../../api/ssp'; -import { useAuth } from '../../app/context/AuthProvider'; import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; import { blueColumn } from '../../components/RealtimeTable/constants/columnColors'; import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig'; -import { ROLES_NAME_ID } from '../../constants/constants'; import { useSvod } from './SvodContext'; +import { SvodFilters } from './SvodFilters'; const SHEETS = [ { value: 'MAIN', label: 'Общий свод' }, - { value: 'FORM_1', label: 'Смета ГО' }, + { value: 'FORM_1', label: 'Смета ССП' }, { value: 'FORM_2', label: 'Смета РФ' }, { value: 'FORM_3', label: 'Проекты РФ Развитие' }, { value: 'FORM_4', label: 'Проектная деятельность' }, @@ -33,8 +26,10 @@ const DIRECTIONS = [ { key: 'development', label: 'Развитие' }, ]; const QUARTERS = [ - { key: 'q1', label: 'I кв.' }, { key: 'q2', label: 'II кв.' }, - { key: 'q3', label: 'III кв.' }, { key: 'q4', label: 'IV кв.' }, + { key: 'q1', label: 'I кв.' }, + { key: 'q2', label: 'II кв.' }, + { key: 'q3', label: 'III кв.' }, + { key: 'q4', label: 'IV кв.' }, { key: 'year', label: 'Год' }, ]; const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }); @@ -69,39 +64,50 @@ const valueColumn = (metric, direction, period) => ({ }); const createColumns = (sheet) => { - const visibleDirections = sheet === 'FORM_2' - ? DIRECTIONS.filter(({ key }) => key === 'support') - : ['FORM_3', 'FORM_4'].includes(sheet) - ? DIRECTIONS.filter(({ key }) => key === 'development') - : DIRECTIONS; + let visibleDirections = DIRECTIONS; + if (sheet === 'FORM_2') visibleDirections = DIRECTIONS.filter(({ key }) => key === 'support'); + if (['FORM_3', 'FORM_4'].includes(sheet)) visibleDirections = DIRECTIONS.filter(({ key }) => key === 'development'); const shape = getShapeForSheet(sheet); const metricColumns = METRICS.map((metric) => ({ id: metric.key, header: metric.label, - columns: visibleDirections.map((direction) => ({ - id: `${metric.key}.${direction.key}`, - header: direction.label, - columns: QUARTERS - .filter(({ key }) => valueAt({ data: shape }, [metric.key, direction.key, key]) !== undefined) - .map((period) => valueColumn(metric, direction, period)), - })).concat(shape[metric.key].total_year === undefined ? [] : [{ - id: `${metric.key}.total`, - header: 'Итого', - columns: [{ - id: `${metric.key}.total_year`, - header: 'Год', - accessorFn: (row) => valueAt(row, [metric.key, 'total_year']), - Cell: ({ cell }) => formatValue(cell.getValue()), - size: tableSize(8.25), - muiTableBodyCellProps: { align: 'right' }, - muiTableHeadCellProps: { align: 'right' }, - }], - }]), + columns: visibleDirections + .map((direction) => ({ + id: `${metric.key}.${direction.key}`, + header: direction.label, + columns: QUARTERS.filter(({ key }) => valueAt({ data: shape }, [metric.key, direction.key, key]) !== undefined).map((period) => + valueColumn(metric, direction, period), + ), + })) + .concat( + shape[metric.key].total_year === undefined + ? [] + : [ + { + id: `${metric.key}.total`, + header: 'Итого', + columns: [ + { + id: `${metric.key}.total_year`, + header: 'Год', + accessorFn: (row) => valueAt(row, [metric.key, 'total_year']), + Cell: ({ cell }) => formatValue(cell.getValue()), + size: tableSize(8.25), + muiTableBodyCellProps: { align: 'right' }, + muiTableHeadCellProps: { align: 'right' }, + }, + ], + }, + ], + ), })); return [ { id: 'section_code', header: 'Код', accessorFn: (row) => row.data?.section_code, size: tableSize(5.75) }, { - id: 'name', header: 'Статья расходов', accessorFn: (row) => row.data?.name, size: tableSize(20.625), + id: 'name', + header: 'Статья расходов', + accessorFn: (row) => row.data?.name, + size: tableSize(20.625), Cell: ({ cell, row }) => ( {cell.getValue() || 'Без названия'} @@ -150,58 +156,36 @@ const buildSummaryTree = (sourceRows) => { return roots; }; -const filterSummaryTree = (tree, query) => tree.flatMap((row) => { - const matches = [row.data?.section_code, row.data?.name] - .some((value) => String(value || '').toLowerCase().includes(query)); +const filterSummaryTree = (tree, query) => + tree.flatMap((row) => { + const matches = [row.data?.section_code, row.data?.name].some((value) => + String(value || '') + .toLowerCase() + .includes(query), + ); - if (matches) return [row]; + if (matches) return [row]; - const subRows = filterSummaryTree(row.subRows, query); - return subRows.length ? [{ ...row, subRows }] : []; -}); + const subRows = filterSummaryTree(row.subRows, query); + return subRows.length ? [{ ...row, subRows }] : []; + }); const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0); -const getAvailableYears = () => { - const currentYear = new Date().getFullYear(); - return Array.from({ length: 7 }, (_, index) => currentYear + 1 - index); -}; - export default function SvodPage() { - const { user } = useAuth(); const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod(); const [sheet, setSheet] = useState('MAIN'); - const [organizations, setOrganizations] = useState([]); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); - const [organizationsLoading, setOrganizationsLoading] = useState(false); const [error, setError] = useState(''); const latestSummaryRequestRef = useRef(0); const { year, selectedOrganizations, search } = getSheetFilters(sheet); - const updateCurrentSheetFilters = useCallback((changes) => { - updateSheetFilters(sheet, changes); - }, [sheet, updateSheetFilters]); - - useEffect(() => { - if (!user) return; - const loadOrganizations = async () => { - setOrganizationsLoading(true); - try { - if (user.role_id === ROLES_NAME_ID.admin) { - const response = await SspApi.getAll({ is_active: true, limit: 100 }); - setOrganizations(response.result || []); - } else { - setOrganizations(user.org_units || []); - } - } catch (_error) { - setOrganizations(user.org_units || []); - toast.error('Не удалось загрузить список ССП/РФ'); - } finally { - setOrganizationsLoading(false); - } - }; - loadOrganizations(); - }, [user]); + const updateCurrentSheetFilters = useCallback( + (changes) => { + updateSheetFilters(sheet, changes); + }, + [sheet, updateSheetFilters], + ); const loadSummary = useCallback(async () => { const requestId = latestSummaryRequestRef.current + 1; @@ -209,7 +193,11 @@ export default function SvodPage() { setLoading(true); setError(''); try { - const response = await getSummary(sheet, year, selectedOrganizations.map(({ id }) => id)); + const response = await getSummary( + sheet, + year, + selectedOrganizations.map(({ id }) => id), + ); if (latestSummaryRequestRef.current !== requestId) return; setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))); } catch (loadError) { @@ -229,7 +217,11 @@ export default function SvodPage() { }, [loadSummary]); const refreshSummary = useCallback(() => { - invalidateSummary(sheet, year, selectedOrganizations.map(({ id }) => id)); + invalidateSummary( + sheet, + year, + selectedOrganizations.map(({ id }) => id), + ); loadSummary(); }, [invalidateSummary, loadSummary, selectedOrganizations, sheet, year]); @@ -297,8 +289,15 @@ export default function SvodPage() { position: 'sticky', top: 0, boxShadow: 'none', - backgroundColor: '#f8f9fa', borderRight: '0.0625rem solid #e5e7eb', - color: '#4a5565', fontSize: '0.75rem', fontWeight: 700, lineHeight: 1.2, px: '0.75rem', py: '0.5rem', whiteSpace: 'nowrap', + backgroundColor: '#f8f9fa', + borderRight: '0.0625rem solid #e5e7eb', + color: '#4a5565', + fontSize: '0.75rem', + fontWeight: 700, + lineHeight: 1.2, + px: '0.75rem', + py: '0.5rem', + whiteSpace: 'nowrap', }, }, muiTableHeadRowProps: { @@ -310,9 +309,13 @@ export default function SvodPage() { const backgroundColor = getSummaryRowColor(row); return { sx: { - backgroundColor, borderRight: '0.0625rem solid #edf0f2', color: getTextColor(backgroundColor), - fontSize: '0.8125rem', px: column.id === 'mrt-row-expand' ? '0.25rem' : '0.75rem', - py: column.id === 'mrt-row-expand' ? 0 : '0.5rem', whiteSpace: 'nowrap', + backgroundColor, + borderRight: '0.0625rem solid #edf0f2', + color: getTextColor(backgroundColor), + fontSize: '0.8125rem', + px: column.id === 'mrt-row-expand' ? '0.25rem' : '0.75rem', + py: column.id === 'mrt-row-expand' ? 0 : '0.5rem', + whiteSpace: 'nowrap', textAlign: ['section_code', 'name'].includes(column.id) ? 'left' : 'right', }, }; @@ -322,39 +325,32 @@ export default function SvodPage() { }); return ( - + - setSheet(value)} variant='scrollable' scrollButtons='auto' + setSheet(value)} + variant='scrollable' + scrollButtons='auto' sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}> - {SHEETS.map((item) => )} + {SHEETS.map((item) => ( + + ))} - - updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}> - {getAvailableYears().map((availableYear) => {availableYear})} - - option.id === value.id} - getOptionLabel={(option) => option.title || ''} onChange={(_event, value) => updateCurrentSheetFilters({ selectedOrganizations: value })} - sx={{ minWidth: '18.75rem', flex: '1 1 22.5rem', maxWidth: '32.5rem' }} - renderTags={(value, getTagProps) => value.slice(0, 2).map((option, index) => { - const tagProps = getTagProps({ index }); - return ; - }).concat(value.length > 2 ? [] : [])} - renderInput={(params) => {organizationsLoading && }{params.InputProps?.endAdornment}, - }} />} - /> - updateCurrentSheetFilters({ search: event.target.value })} - placeholder='Код или статья расходов' sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }} - InputProps={{ startAdornment: }} /> + {!loading && filteredRowsCount !== rows.length && ( {`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`} @@ -369,12 +365,21 @@ export default function SvodPage() { sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}> Обновить - + {error ? ( - Повторить}>{error} - ) : } + + Повторить + + }> + {error} + + ) : ( + + )} ); } diff --git a/web/src/pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx b/web/src/pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx new file mode 100644 index 0000000..37b248f --- /dev/null +++ b/web/src/pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx @@ -0,0 +1,139 @@ +import styled from '@emotion/styled'; +import { Stack } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; + +import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch'; +import { PrimaryButton } from '../../components/common/Buttons/Buttons'; +import { TransitionSvg } from '../../components/common/icons/icons'; +import { PageContainer } from '../StyledForPage'; + +const CardsContainer = styled.div` + display: flex; + flex-wrap: wrap; + gap: 1.5rem; +`; + +const Card = styled.div` + background: white; + border-radius: 0.75rem; + box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 115rem; + display: flex; + flex-direction: column; +`; + +const CardContent = styled.div` + padding: 2.5rem; + width: 51rem; + max-width: 100%; + box-sizing: border-box; + flex: 1; +`; + +const CardTitle = styled.h2` + font-size: 1.13rem; + line-height: 150%; + color: #2d3748; +`; + +const CardDescription = styled.p` + font-family: var(--font-family); + font-weight: 400; + font-size: 0.94rem; + line-height: 160%; + color: #4a5565; + margin-top: 0.7rem; +`; + +const FeatureList = styled.ul` + list-style: none; + padding: 0; + margin-top: 1rem; + margin-bottom: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +const FeatureItem = styled.li` + color: #4a5565; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.88rem; + line-height: 157%; + + &:before { + content: '•'; + color: #4a5565; + font-weight: bold; + font-size: 1rem; + } +`; + +function SvodsNavigatorPage() { + const navigate = useNavigate(); + + return ( + <> + + + + + + Свод 1 + + Консолидированное представление плановых и фактических показателей по формам бюджетного планирования. + + + Просмотр общего свода и данных по формам + Фильтрация по году и подразделениям + Поиск по статьям расходов + + navigate('/svod')}> + + +

Открыть свод

+
+
+
+
+ + + Свод 2 + + Детализированное представление договорных строк по головному офису и региональной филиальной сети. + + + Просмотр данных по ГО и РФ + Фильтрация по году и подразделениям + Поиск по статьям и реквизитам договоров + + navigate('/svod/2')}> + + +

Открыть свод

+
+
+
+
+
+
+ + ); +} + +export default SvodsNavigatorPage; -- 2.47.2 From ee3b87c69a35dcb0404107e2afc9da7608c2195c Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Tue, 1 Sep 2026 15:14:43 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=D0=BE=D0=B3=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B8=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/app/Routes.jsx | 2 - .../RealtimeTable/RealtimeTable.jsx | 27 +++++++++-- .../SettingPanel/SettingPanel.jsx | 3 +- web/src/components/Stages/SettingModal.jsx | 47 +++++++++++-------- .../components/common/TableCard/TableCard.jsx | 2 +- .../UsersPage/UserTable/EditUserModal.jsx | 18 +++---- web/src/pages/NewFormTablePage.jsx | 2 +- web/src/pages/NewTablePage.jsx | 27 ----------- .../components/TableFilters/TableFilters.jsx | 22 +++++---- web/src/pages/SvodPage/SvodFilters.jsx | 39 ++++++++------- web/src/pages/SvodPage/SvodPage.jsx | 2 +- 11 files changed, 99 insertions(+), 92 deletions(-) delete mode 100644 web/src/pages/NewTablePage.jsx diff --git a/web/src/app/Routes.jsx b/web/src/app/Routes.jsx index 94e8399..57b4861 100644 --- a/web/src/app/Routes.jsx +++ b/web/src/app/Routes.jsx @@ -8,7 +8,6 @@ import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.j import FormsPage from '../pages/FormsPage'; import LoginPage from '../pages/LoginPage'; import NewFormTablePage from '../pages/NewFormTablePage.jsx'; -import NewTablePage from '../pages/NewTablePage.jsx'; import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx'; import Svod2Page from '../pages/Svod2Page/Svod2Page.jsx'; import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx'; @@ -52,7 +51,6 @@ export const AppRoutes = () => { } /> } /> } /> - } /> } /> } /> } /> diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 1a26774..827babb 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -26,6 +26,7 @@ import { getRowId } from './utils/rowUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; +import { ROLES_NAME_ID } from '../../constants/constants'; import { FormsNoCanAddRow } from './constants/formConfig'; const CONFIG_CACHE = new Map(); @@ -121,6 +122,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [createModalType, setCreateModalType] = useState(null); const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]); + const canModifyRows = userRoleId === ROLES_NAME_ID.admin || columnsCurStage.length > 0; const isVspAdditionRow = useMemo(() => { if (!formType || !sheetName) return false; @@ -664,6 +666,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [getSelectedRow]); const handleDeleteRow = useCallback(() => { + if (!canModifyRows) { + toast.error('Удаление строк недоступно: нет доступных для редактирования столбцов'); + return; + } const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для удаления'); @@ -671,9 +677,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { } contextDeleteRow(getRowId(row)); setRowSelection({}); - }, [contextDeleteRow, getSelectedRow]); + }, [canModifyRows, contextDeleteRow, getSelectedRow]); const addRow = useCallback(() => { + if (!canModifyRows) { + toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); + return; + } if (isVspAdditionRow) { return handleOpenModalSelectVsp(); } @@ -681,13 +691,21 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return handleOpenModalSelectExpenseItem(); } return handleAddRow(); - }, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); + }, [canModifyRows, isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); const addProgram = useCallback(() => { + if (!canModifyRows) { + toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); + return; + } setCreateModalType('program'); - }, []); + }, [canModifyRows]); const addProject = useCallback(() => { + if (!canModifyRows) { + toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); + return; + } const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для вставки'); @@ -698,7 +716,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return; } setCreateModalType('project'); - }, [getSelectedRow]); + }, [canModifyRows, getSelectedRow]); const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []); const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []); @@ -777,6 +795,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { direction={direction} year={year} isFormNoCanAddRow={isFormNoCanAddRow} + canModifyRows={canModifyRows} formType={formType} validationErrors={validationErrors} onNavigateToColumn={handleNavigateToColumn} diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index c8129c2..81fb04b 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -90,6 +90,7 @@ const SettingPanel = ({ direction, year, isFormNoCanAddRow, + canModifyRows, formType, validationErrors = [], onNavigateToColumn, @@ -178,7 +179,7 @@ const SettingPanel = ({ - {!isFormNoCanAddRow && ( + {!isFormNoCanAddRow && canModifyRows && ( <> diff --git a/web/src/components/Stages/SettingModal.jsx b/web/src/components/Stages/SettingModal.jsx index 4c45709..32a59b7 100644 --- a/web/src/components/Stages/SettingModal.jsx +++ b/web/src/components/Stages/SettingModal.jsx @@ -1,8 +1,9 @@ import { Box, CircularProgress, Stack } from '@mui/material'; import { useEffect, useState } from 'react'; +import { useAuth } from '../../app/context/AuthProvider'; import { useTaskStages } from '../../hooks/useTaskStages'; import { useProjectStages } from '../../hooks/useProjectStages'; -import { SHEET_NAME } from '../../constants/constants'; +import { ROLES_NAME_ID, SHEET_NAME } from '../../constants/constants'; import { PrimaryButton } from '../common/Buttons/Buttons'; import Modal from '../common/Modal/Modal'; import { EditContainer } from '../common/Modal/ModalStyled'; @@ -23,6 +24,8 @@ export const SettingModal = ({ formTypeCode = '', sheets, }) => { + const { user } = useAuth(); + const isAdmin = user?.role_id === ROLES_NAME_ID.admin; const taskStages = useTaskStages(taskId); const projectStages = useProjectStages(projectId, year, reportType); const stages = mode === 'project' ? projectStages : taskStages; @@ -137,11 +140,13 @@ export const SettingModal = ({ <> - - }> - Добавить этап - - + {isAdmin && ( + + }> + Добавить этап + + + )} {stagesInfo.isLoading ? ( )} - { - setIsAddEditModalOpen(false); - }} - stage={stageForEdit} - createStage={createStage} - updateStage={updateStage} - isTaskMode={isTaskMode} - isSaving={isSaving} - sheetOptions={sheetOptions} - formType={formTypeCode} - defaultSheet={mode === 'project' ? reportType : ''} - /> + {isAdmin && ( + { + setIsAddEditModalOpen(false); + }} + stage={stageForEdit} + createStage={createStage} + updateStage={updateStage} + isTaskMode={isTaskMode} + isSaving={isSaving} + sheetOptions={sheetOptions} + formType={formTypeCode} + defaultSheet={mode === 'project' ? reportType : ''} + /> + )} {!isTaskMode && ( {

{table.name}

-

{DIRECTION_TRANSLATE[table?.direction]}

+

{DIRECTION_TRANSLATE[table?.direction] || ''}

{/*
*/} diff --git a/web/src/pages/AdminPanel/UsersPage/UserTable/EditUserModal.jsx b/web/src/pages/AdminPanel/UsersPage/UserTable/EditUserModal.jsx index 59e69f5..650133a 100644 --- a/web/src/pages/AdminPanel/UsersPage/UserTable/EditUserModal.jsx +++ b/web/src/pages/AdminPanel/UsersPage/UserTable/EditUserModal.jsx @@ -167,14 +167,16 @@ export const EditUserModal = ({ isOpen, onClose, onConfirm, user, onEditSsp }) = variant='outlined' size='small' autoFocus - InputProps={{ - endAdornment: ( - - toggleEdit(field)} size='small' sx={{ padding: '4px' }}> - - - - ), + slotProps={{ + input: { + endAdornment: ( + + toggleEdit(field)} size='small' sx={{ padding: '4px' }}> + + + + ), + }, }} />
diff --git a/web/src/pages/NewFormTablePage.jsx b/web/src/pages/NewFormTablePage.jsx index 4a28f8a..f89dc21 100644 --- a/web/src/pages/NewFormTablePage.jsx +++ b/web/src/pages/NewFormTablePage.jsx @@ -10,7 +10,7 @@ import { DIRECTION_TRANSLATE, SHEET_NAME } from '../constants/constants'; const TableInfo = ({ formId, sheetName, direction, isProject = false }) => { const path = (isProject ? '/project/' : '/task/') + formId; const tableName = SHEET_NAME[sheetName] || sheetName; - const directionName = DIRECTION_TRANSLATE[direction] || direction; + const directionName = DIRECTION_TRANSLATE[direction] || ''; const portalContent = ( diff --git a/web/src/pages/NewTablePage.jsx b/web/src/pages/NewTablePage.jsx deleted file mode 100644 index ba26b34..0000000 --- a/web/src/pages/NewTablePage.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createPortal } from 'react-dom'; -import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style'; - -import RealtimeTable from '../components/RealtimeTable'; - -const TableInfo = () => { - const portalContent = ( - - {/* - {table.name} */} - - ); - - const targetElement = document.getElementById('TableInfo'); - - if (targetElement) { - return createPortal(portalContent, targetElement); - } -}; - -export default function NewTablePage() { - return ( - <> - - - ); -} diff --git a/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx b/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx index 75624db..ef09ee0 100644 --- a/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx +++ b/web/src/pages/ProjectPages/components/TableFilters/TableFilters.jsx @@ -93,21 +93,23 @@ const TableFilters = ({ onBranchChange(newValue || ''); }} renderInput={(params) => { - const { InputProps, ...rest } = params; return ( - {isLoading && } - {InputProps?.endAdornment} - - ), + slotProps={{ + ...params.slotProps, + input: { + ...params.slotProps.input, + endAdornment: ( + <> + {isLoading && } + {params.slotProps.input.endAdornment} + + ), + }, }} /> ); diff --git a/web/src/pages/SvodPage/SvodFilters.jsx b/web/src/pages/SvodPage/SvodFilters.jsx index 35ce326..8d9c63a 100644 --- a/web/src/pages/SvodPage/SvodFilters.jsx +++ b/web/src/pages/SvodPage/SvodFilters.jsx @@ -85,12 +85,12 @@ export function SvodFilters({ onChange({ selectedOrganizations: selectedAll ? [SELECT_ALL_OPTION] : value }); }} sx={{ minWidth: '18.75rem', flex: '1 1 22.5rem', maxWidth: '32.5rem' }} - renderTags={(value, getTagProps) => + renderValue={(value, getItemProps) => value .slice(0, 2) .map((option, index) => { - const tagProps = getTagProps({ index }); - return ; + const { key, ...itemProps } = getItemProps({ index }); + return ; }) .concat(value.length > 2 ? [] : []) } @@ -99,14 +99,17 @@ export function SvodFilters({ {...params} label='ССП/РФ' placeholder={selectedOrganizations.length ? '' : emptyOrganizationsPlaceholder} - InputProps={{ - ...(params.InputProps || {}), - endAdornment: ( - <> - {organizationsLoading && } - {params.InputProps?.endAdornment} - - ), + slotProps={{ + ...params.slotProps, + input: { + ...params.slotProps.input, + endAdornment: ( + <> + {organizationsLoading && } + {params.slotProps.input.endAdornment} + + ), + }, }} /> )} @@ -117,12 +120,14 @@ export function SvodFilters({ onChange={(event) => onChange({ search: event.target.value })} placeholder={searchPlaceholder} sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }} - InputProps={{ - startAdornment: ( - - - - ), + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, }} /> {children} diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx index 7eb62c0..7184bd3 100644 --- a/web/src/pages/SvodPage/SvodPage.jsx +++ b/web/src/pages/SvodPage/SvodPage.jsx @@ -44,7 +44,7 @@ const formatValue = (value) => { const getShapeForSheet = (sheet) => { const directionShape = { q1: 0, q2: 0, q3: 0, q4: 0, year: 0 }; - const correctedShape = sheet === 'FORM_4' ? directionShape : { q2: 0, q3: 0, q4: 0 }; + const correctedShape = { q2: 0, q3: 0, q4: 0 }; return { plan: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) }, approved: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) }, -- 2.47.2