2026-09-03 15:30:14 +03:00

611 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 квартала во 24 кварталы',
2: 'Корректировка из 2 квартала в 34 кварталы',
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 (
<Box
component='span'
sx={{
display: 'inline-block',
px: '0.625rem',
py: '0.25rem',
border: '0.0625rem solid',
borderRadius: '0.5rem',
fontWeight: 600,
lineHeight: 1.35,
whiteSpace: 'nowrap',
...colors,
}}>
{value}
</Box>
);
},
}),
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 (
<Box
component='span'
sx={{
display: 'inline-block',
maxWidth: '100%',
px: '0.625rem',
py: '0.25rem',
border: '0.0625rem solid #a7d9b8',
borderRadius: '0.5rem',
backgroundColor: '#ecf9f0',
color: '#08783e',
fontWeight: 600,
lineHeight: 1.35,
textTransform: 'uppercase',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{value}
</Box>
);
},
}),
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 = <MaterialReactTable table={table} />;
if (!hasCurrentData && !loading && !error) {
content = (
<Alert severity='info'>
{selectedOrganizations.length === 0
? 'Выберите одно или несколько ССП/РФ либо пункт «Все ССП/РФ», затем нажмите «Загрузить».'
: 'Нажмите «Загрузить», чтобы получить данные по выбранным параметрам.'}
</Alert>
);
} else if (error) {
content = (
<Alert
severity='error'
action={
<Button color='inherit' size='small' onClick={refreshSummary}>
Повторить
</Button>
}>
{error}
</Alert>
);
}
return (
<Box
sx={{
px: '2rem',
pt: '1rem',
pb: '1.5rem',
height: '100%',
minHeight: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}>
<HeaderSwitch />
<Paper elevation={0} sx={{ border: '0.0625rem solid #e5e7eb', borderRadius: '0.75rem', overflow: 'hidden', flexShrink: 0 }}>
<Tabs
value={sheet}
onChange={(_event, value) => setSheet(value)}
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
{SHEETS.map((item) => (
<Tab key={item.value} value={item.value} label={item.label} sx={{ textTransform: 'none', minHeight: '3rem' }} />
))}
</Tabs>
<SvodFilters
year={year}
selectedOrganizations={selectedOrganizations}
search={search}
onChange={updateCurrentSheetFilters}
searchPlaceholder='ССП/РФ, код, статья или договор'
emptyOrganizationsPlaceholder='Выберите ССП/РФ'
allowSelectAll>
{hasCurrentData && !loading && filteredRows.length !== rows.length && (
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
{`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
</Typography>
)}
{hasCurrentData ? (
<Button
variant='outlined'
color='default'
startIcon={<RefreshRoundedIcon />}
disabled={loading || selectedOrganizations.length === 0}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Обновить
</Button>
) : (
<PrimaryButton
variant='outlined'
color='default'
disabled={loading || selectedOrganizations.length === 0}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Загрузить
</PrimaryButton>
)}
</SvodFilters>
</Paper>
{content}
</Box>
);
}