Andrey Epifancev b0de763259 perf(dict-vsp): виртуализация строк, состояние модалок вынесено из таблицы
Модалки в DictVspModals (forwardRef), таблица в memo со стабильными колбэками,
enableRowVirtualization, size/minSize/maxSize колонок выверены под grid-раскладку.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:09:52 +04:00

456 lines
12 KiB
JavaScript

import { Box, IconButton, Stack, Typography } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { memo, useMemo, useRef } from 'react';
import { useAuth } from '../../../app/context/AuthProvider';
import { PrimaryButton, PrimaryOutlinedButton } from '../../../components/common/Buttons/Buttons';
import { EditPenSvg } from '../../../components/common/icons/icons';
import { ROLES_NAME_ID } from '../../../constants/constants';
import { filterVspBySsp } from '../utils/filterVspBySsp';
const ROW_ESTIMATED_HEIGHT = 54;
const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading = false, selectedSsp }) => {
const { user } = useAuth();
const tableContainerRef = useRef(null);
const filteredData = useMemo(() => filterVspBySsp(data, selectedSsp), [data, selectedSsp]);
const columns = useMemo(
() => [
{
accessorKey: 'index',
header: '№',
size: 60,
minSize: 50,
maxSize: 70,
enableColumnActions: false,
enableSorting: false,
Cell: ({ row }) => (
<Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}>
{row.index + 1}
</Typography>
),
},
{
accessorKey: 'system_code',
header: 'Код РФ',
size: 110,
minSize: 90,
maxSize: 140,
Cell: ({ cell }) => (
<Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}>
{cell.getValue() || '-'}
</Typography>
),
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'registration_number',
header: 'Регистрационный номер ВСП',
size: 250,
minSize: 200,
maxSize: 300,
Cell: ({ cell }) => (
<Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}>
{cell.getValue() || '-'}
</Typography>
),
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'regional_branch',
header: 'Региональный филиал/ССП',
size: 230,
minSize: 180,
maxSize: 300,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'vsp_type',
header: 'Тип ВСП',
size: 120,
minSize: 100,
maxSize: 150,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'address',
header: 'Адрес объекта',
size: 300,
minSize: 200,
maxSize: 500,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'approved_format',
header: 'Утверждённый формат',
size: 190,
minSize: 160,
maxSize: 240,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'open_date',
header: 'Дата открытия ВСП',
size: 180,
minSize: 150,
maxSize: 220,
Cell: ({ cell }) => {
const date = cell.getValue();
return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>;
},
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'close_date',
header: 'Дата закрытия ВСП',
size: 180,
minSize: 150,
maxSize: 220,
Cell: ({ cell }) => {
const date = cell.getValue();
return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>;
},
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'placement_type',
header: 'Форма расположения ВСП',
size: 220,
minSize: 180,
maxSize: 260,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'staff_count',
header: 'Штатная численность ВСП',
size: 230,
minSize: 190,
maxSize: 270,
Cell: ({ cell }) => {
const value = cell.getValue();
return <Typography variant='body2'>{value !== undefined && value !== null ? value : '-'}</Typography>;
},
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'area',
header: 'Площадь',
size: 100,
minSize: 80,
maxSize: 120,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'rent_contract_num',
header: 'Номер договора',
size: 150,
minSize: 130,
maxSize: 200,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'rent_end_date',
header: 'Дата окончания договора',
size: 230,
minSize: 190,
maxSize: 270,
Cell: ({ cell }) => {
const date = cell.getValue();
return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>;
},
enableColumnActions: false,
enableSorting: false,
},
{
accessorKey: 'notes',
header: 'Примечания',
size: 300,
minSize: 200,
maxSize: 500,
Cell: ({ cell }) => (
<Typography variant='body2' sx={{ whiteSpace: 'pre-wrap' }}>
{cell.getValue() || '-'}
</Typography>
),
enableColumnActions: false,
enableSorting: false,
},
],
[],
);
const table = useMaterialReactTable({
columns,
data: filteredData,
enableEditing: true,
enableRowActions: true,
enableTopToolbar: true,
enableColumnFilters: false,
enableGlobalFilter: false,
enableSorting: false,
enablePagination: false,
enableRowVirtualization: true,
// Высоту строк MRT измеряет сам (по умолчанию): «Примечания» многострочные (pre-wrap).
rowVirtualizerOptions: { overscan: 5, estimateSize: () => ROW_ESTIMATED_HEIGHT },
positionActionsColumn: 'last',
// Включаем изменение размера колонок для лучшего UX при горизонтальном скролле
// enableColumnResizing: true,
columnResizeMode: 'onChange',
enableToolbarInternalActions: false,
initialState: {
columnPinning: {
left: ['index', 'system_code', 'registration_number'],
right: ['mrt-row-actions'],
},
},
renderTopToolbar: () => (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}>
<Typography
sx={{
fontWeight: 700,
fontSize: '1.25rem',
lineHeight: '140%',
color: '#2d3748',
}}>
Справочник объектов
</Typography>
<Stack spacing={'.5rem'} direction={'row'}>
<PrimaryOutlinedButton onClick={onExportClick}>Экспортировать</PrimaryOutlinedButton>
{user.role_id == ROLES_NAME_ID.admin && <PrimaryButton onClick={onAddClick}>+ Добавить объект</PrimaryButton>}
</Stack>
</Box>
),
muiTopToolbarProps: {
sx: {
backgroundColor: '#F9FAFB',
minHeight: 'auto',
padding: '1rem 1rem 0 1rem',
position: 'sticky',
top: 0,
zIndex: 10,
'& .MuiToolbar-root': {
padding: 0,
minHeight: 'auto',
width: '100%',
},
},
},
displayColumnDefOptions: {
'mrt-row-actions': {
header: 'Действия',
size: 80,
muiTableHeadCellProps: {
align: 'center',
},
muiTableBodyCellProps: {
align: 'center',
},
},
},
renderRowActions: ({ row }) => (
<>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
}}>
<IconButton
size='small'
onClick={() => onEditClick(row.original)}
sx={{
padding: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
}}
title='Редактировать'>
<EditPenSvg fill='#6FB246' />
</IconButton>
</Box>
</>
),
state: {
isLoading: loading,
},
muiTablePaperProps: {
sx: {
borderRadius: '.63rem',
border: '1px solid #f2f2f2',
boxShadow: 'none',
overflow: 'hidden',
position: 'relative',
padding: '20px',
width: '100%',
},
},
muiTableContainerProps: {
ref: tableContainerRef,
sx: {
maxHeight: 'calc(100vh - 450px)',
minHeight: '40rem',
width: '100%',
marginTop: '1.5rem',
overflowX: 'auto', // Включаем горизонтальный скролл
overflowY: 'auto', // Вертикальный скролл
position: 'relative',
// Добавляем индикатор скролла для лучшего UX
'&::-webkit-scrollbar': {
height: '8px',
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: '#f1f1f1',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: '#c1c1c1',
borderRadius: '4px',
'&:hover': {
backgroundColor: '#a8a8a8',
},
},
},
},
// Обеспечиваем минимальную ширину таблицы для скролла
muiTableProps: {
sx: {
minWidth: 'max-content', // Таблица будет занимать минимум необходимую ширину
width: '100%',
},
},
muiTableBodyRowProps: {
sx: {
'& .MuiTableCell-root': {
padding: '0.75rem',
},
'&:hover': {
backgroundColor: 'white !important',
},
'&:hover td::after': {
opacity: '0 !important',
backgroundColor: 'transparent !important',
},
'&:hover .MuiTableCell-root': {
backgroundColor: 'white !important',
},
},
},
muiTableHeadProps: {
sx: {
position: 'sticky',
top: 0,
zIndex: 10,
},
},
muiTableHeadRowProps: {
sx: {
position: 'sticky',
backgroundColor: '#F9FAFB',
color: '#4A5565',
fontSize: '0.88rem',
'& .MuiTableCell-root': {
padding: '1rem 0.75rem',
fontWeight: 'bold',
backgroundColor: '#F9FAFB',
whiteSpace: 'nowrap', // Заголовки тоже не переносим
},
'& .Mui-TableHeadCell-Content': {
'& .Mui-TableHeadCell-Content-Labels': {
overflow: 'visible',
},
'& .MuiSvgIcon-root': {
fontSize: '1.2rem',
marginLeft: '8px',
overflow: 'visible !important',
},
},
},
},
muiTableBodyCellProps: {
sx: {
padding: '0.75rem',
fontSize: '0.75rem',
lineHeight: '150%',
color: '#364153',
whiteSpace: 'nowrap', // Предотвращаем перенос текста
},
},
muiTableHeadCellProps: {
sx: {
padding: '1rem 0.75rem',
fontSize: 'inherit',
fontWeight: 'bold',
whiteSpace: 'nowrap', // Заголовки не переносим
'& .MuiSvgIcon-root': {
fontSize: '1.2rem',
marginLeft: '4px',
flexShrink: 0,
},
},
},
muiBottomToolbarProps: {
sx: {
display: 'none',
},
},
localization: {
noRecordsToDisplay: 'Нет данных для отображения',
},
});
return (
<Box sx={{ position: 'relative', width: '100%' }}>
<MaterialReactTable table={table} />
</Box>
);
};
export default memo(TableDictVsp);