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 createInitialTableState = () => Object.fromEntries(SHEETS.map(({ value }) => [value, { rows: [], loading: false, error: '', loadedQueryKey: '' }])); 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 COLUMN_CONFIG_CACHE = new Map(); const getColumnConfig = (sheet) => { if (!COLUMN_CONFIG_CACHE.has(sheet)) { const columns = disableGroupColumnFilters(createColumns(sheet)); COLUMN_CONFIG_CACHE.set(sheet, { columns, columnBands: createColumnBandMap(columns), columnGroupEnds: createColumnGroupEndSet(columns), }); } return COLUMN_CONFIG_CACHE.get(sheet); }; 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 [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState); const [columnFiltersBySheet, setColumnFiltersBySheet] = useState(() => Object.fromEntries(SHEETS.map(({ value }) => [value, []]))); const latestSummaryRequestRef = useRef(Object.fromEntries(SHEETS.map(({ value }) => [value, 0]))); const { year, selectedOrganizations, search } = getSheetFilters(sheet); const { rows, loading, error, loadedQueryKey } = tableStateBySheet[sheet]; const columnFilters = columnFiltersBySheet[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 updateCurrentColumnFilters = useCallback( (updater) => { setColumnFiltersBySheet((current) => { const currentFilters = current[sheet]; const nextFilters = typeof updater === 'function' ? updater(currentFilters) : updater; return nextFilters === currentFilters ? current : { ...current, [sheet]: nextFilters }; }); }, [sheet], ); const loadSummary = useCallback(async () => { const requestId = latestSummaryRequestRef.current[sheet] + 1; latestSummaryRequestRef.current[sheet] = requestId; setTableStateBySheet((current) => ({ ...current, [sheet]: { ...current[sheet], loading: true, error: '' }, })); try { const response = await getSummary(sheet, year, selectedOrganizationIds); if (latestSummaryRequestRef.current[sheet] !== requestId) return; setTableStateBySheet((current) => ({ ...current, [sheet]: { ...current[sheet], rows: response.result || [], loadedQueryKey: currentQueryKey }, })); } catch (loadError) { if (latestSummaryRequestRef.current[sheet] !== requestId) return; setTableStateBySheet((current) => ({ ...current, [sheet]: { ...current[sheet], rows: [], error: loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.', }, })); } finally { if (latestSummaryRequestRef.current[sheet] === requestId) { setTableStateBySheet((current) => ({ ...current, [sheet]: { ...current[sheet], loading: false }, })); } } }, [currentQueryKey, getSummary, selectedOrganizationIds, sheet, year]); useEffect(() => { return () => { SHEETS.forEach(({ value }) => { latestSummaryRequestRef.current[value] += 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, columnBands, columnGroupEnds } = useMemo(() => getColumnConfig(sheet), [sheet]); 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, onColumnFiltersChange: updateCurrentColumnFilters, enableDensityToggle: false, enableFullScreenToggle: false, enableHiding: false, enablePagination: false, enableRowVirtualization: true, enableTopToolbar: false, enableBottomToolbar: false, enableSorting: false, rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, state: { isLoading: loading, columnFilters }, 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)} 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} ); }