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 ? (
}
disabled={loading || selectedOrganizations.length === 0}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Обновить
) : (
Загрузить
)}
{content}
);
}