perf(dict-vsp): виртуализация строк, состояние модалок вынесено из таблицы

Модалки в DictVspModals (forwardRef), таблица в memo со стабильными колбэками,
enableRowVirtualization, size/minSize/maxSize колонок выверены под grid-раскладку.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 18:09:52 +04:00
parent 3ab229f6f8
commit b0de763259
3 changed files with 137 additions and 106 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style'; import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
import { Box, CircularProgress, Stack } from '@mui/material'; import { Box, CircularProgress, Stack } from '@mui/material';
@ -11,6 +11,7 @@ import { HeaderPortal } from '../../components/common/HeaderPortal';
import { ROLES_NAME_ID } from '../../constants/constants'; import { ROLES_NAME_ID } from '../../constants/constants';
import { PageContainer } from '../../components/common/PageContainer'; import { PageContainer } from '../../components/common/PageContainer';
import DictVspFilters from './components/DictVspFilters'; import DictVspFilters from './components/DictVspFilters';
import DictVspModals from './components/DictVspModals';
import TableDictVsp from './components/TableDictVsp'; import TableDictVsp from './components/TableDictVsp';
const DictVspInfo = () => { const DictVspInfo = () => {
@ -30,6 +31,7 @@ function DictVspPage() {
const [ssps, setSsps] = useState([]); const [ssps, setSsps] = useState([]);
const [selectedSsp, setSelectedSsp] = useState(null); const [selectedSsp, setSelectedSsp] = useState(null);
const { user } = useAuth(); const { user } = useAuth();
const modalsRef = useRef(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!user) return; if (!user) return;
@ -68,7 +70,7 @@ function DictVspPage() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
const handleAdd = async (formData) => { const handleAdd = useCallback(async (formData) => {
try { try {
const response = await DictVspApi.create(formData); const response = await DictVspApi.create(formData);
if (response.success) { if (response.success) {
@ -82,9 +84,9 @@ function DictVspPage() {
toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); toast.error(error.response?.data?.message || 'Ошибка создания ВСП');
throw error; throw error;
} }
}; }, []);
const handleEdit = async (formData) => { const handleEdit = useCallback(async (formData) => {
try { try {
const response = await DictVspApi.update(formData.id, formData); const response = await DictVspApi.update(formData.id, formData);
if (response.success) { if (response.success) {
@ -98,9 +100,9 @@ function DictVspPage() {
toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); toast.error(error.response?.data?.message || 'Ошибка обновления ВСП');
throw error; throw error;
} }
}; }, []);
const handleDelete = async (vspId) => { const handleDelete = useCallback(async (vspId) => {
try { try {
const response = await DictVspApi.delete(vspId); const response = await DictVspApi.delete(vspId);
if (response.success) { if (response.success) {
@ -114,7 +116,11 @@ function DictVspPage() {
toast.error(error.response?.data?.message || 'Ошибка удаления ВСП'); toast.error(error.response?.data?.message || 'Ошибка удаления ВСП');
throw error; throw error;
} }
}; }, []);
const handleAddClick = useCallback(() => modalsRef.current?.openAdd(), []);
const handleEditClick = useCallback((item) => modalsRef.current?.openEdit(item), []);
const handleExportClick = useCallback(() => modalsRef.current?.openExport(), []);
if (isLoading) { if (isLoading) {
return ( return (
@ -139,12 +145,18 @@ function DictVspPage() {
)} )}
<TableDictVsp <TableDictVsp
data={dictVspData} data={dictVspData}
ssps={ssps}
selectedSsp={selectedSsp} selectedSsp={selectedSsp}
onAddClick={handleAddClick}
onEditClick={handleEditClick}
onExportClick={handleExportClick}
loading={isLoading}
/>
<DictVspModals
ref={modalsRef}
ssps={ssps}
onAdd={handleAdd} onAdd={handleAdd}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
loading={isLoading}
/> />
</Box> </Box>
</Stack> </Stack>

View File

@ -0,0 +1,65 @@
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
import ModalEditAddVsp from './Modals/ModalEditAddVsp';
import ModalExport from './Modals/ModalExport';
// Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT.
const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => {
const [modalOpen, setModalOpen] = useState(false);
const [modalExportOpen, setModalExportOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [saveLoading, setSaveLoading] = useState(false);
useImperativeHandle(
ref,
() => ({
openAdd: () => {
setEditingItem(null);
setModalOpen(true);
},
openEdit: (item) => {
setEditingItem(item);
setModalOpen(true);
},
openExport: () => setModalExportOpen(true),
}),
[],
);
const handleSave = async (formData) => {
setSaveLoading(true);
try {
if (editingItem) {
await onEdit?.({ ...formData, id: editingItem.id });
} else {
await onAdd?.(formData);
}
setModalOpen(false);
} catch (error) {
console.error('Error saving:', error);
} finally {
setSaveLoading(false);
}
};
const closeEdit = useCallback(() => setModalOpen(false), []);
const closeExport = useCallback(() => setModalExportOpen(false), []);
return (
<>
<ModalEditAddVsp
open={modalOpen}
onClose={closeEdit}
onSave={handleSave}
onDelete={onDelete}
initialData={editingItem}
loading={saveLoading}
ssps={ssps}
/>
<ModalExport open={modalExportOpen} onClose={closeExport} ssps={ssps} />
</>
);
});
DictVspModals.displayName = 'DictVspModals';
export default DictVspModals;

View File

@ -1,55 +1,19 @@
import { Box, IconButton, Stack, Typography } from '@mui/material'; import { Box, IconButton, Stack, Typography } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { useMemo, useRef, useState } from 'react'; import { memo, useMemo, useRef } from 'react';
import { useAuth } from '../../../app/context/AuthProvider'; import { useAuth } from '../../../app/context/AuthProvider';
import { PrimaryButton, PrimaryOutlinedButton } from '../../../components/common/Buttons/Buttons'; import { PrimaryButton, PrimaryOutlinedButton } from '../../../components/common/Buttons/Buttons';
import { EditPenSvg } from '../../../components/common/icons/icons'; import { EditPenSvg } from '../../../components/common/icons/icons';
import { ROLES_NAME_ID } from '../../../constants/constants'; import { ROLES_NAME_ID } from '../../../constants/constants';
import ModalEditAddVsp from './Modals/ModalEditAddVsp'; import { filterVspBySsp } from '../utils/filterVspBySsp';
import ModalExport from './Modals/ModalExport';
const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, selectedSsp }) => { const ROW_ESTIMATED_HEIGHT = 54;
const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading = false, selectedSsp }) => {
const { user } = useAuth(); const { user } = useAuth();
const [modalOpen, setModalOpen] = useState(false);
const [modalExportOpen, setModalExportOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null);
const [saveLoading, setSaveLoading] = useState(false);
const tableContainerRef = useRef(null); const tableContainerRef = useRef(null);
const filteredData = useMemo(() => { const filteredData = useMemo(() => filterVspBySsp(data, selectedSsp), [data, selectedSsp]);
if (!selectedSsp) {
return data;
}
return data.filter((item) => item.ssp_id === selectedSsp.id);
}, [data, selectedSsp]);
const handleEdit = (item) => {
setEditingItem(item);
setModalOpen(true);
};
const handleAdd = () => {
setEditingItem(null);
setModalOpen(true);
};
const handleExport = () => [setModalExportOpen(true)];
const handleSave = async (formData) => {
setSaveLoading(true);
try {
if (editingItem) {
await onEdit?.({ ...formData, id: editingItem.id });
} else {
await onAdd?.(formData);
}
setModalOpen(false);
} catch (error) {
console.error('Error saving:', error);
} finally {
setSaveLoading(false);
}
};
const columns = useMemo( const columns = useMemo(
() => [ () => [
@ -70,9 +34,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'system_code', accessorKey: 'system_code',
header: 'Код РФ', header: 'Код РФ',
size: 180, size: 110,
minSize: 50, minSize: 90,
maxSize: 100, maxSize: 140,
Cell: ({ cell }) => ( Cell: ({ cell }) => (
<Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}> <Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}>
{cell.getValue() || '-'} {cell.getValue() || '-'}
@ -84,9 +48,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'registration_number', accessorKey: 'registration_number',
header: 'Регистрационный номер ВСП', header: 'Регистрационный номер ВСП',
size: 180, size: 250,
minSize: 150, minSize: 200,
maxSize: 200, maxSize: 300,
Cell: ({ cell }) => ( Cell: ({ cell }) => (
<Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}> <Typography variant='body2' component='div' sx={{ fontWeight: 'medium' }}>
{cell.getValue() || '-'} {cell.getValue() || '-'}
@ -98,9 +62,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'regional_branch', accessorKey: 'regional_branch',
header: 'Региональный филиал/ССП', header: 'Региональный филиал/ССП',
size: 120, size: 230,
minSize: 100, minSize: 180,
maxSize: 150, maxSize: 300,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>, Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false, enableColumnActions: false,
enableSorting: false, enableSorting: false,
@ -118,9 +82,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'address', accessorKey: 'address',
header: 'Адрес объекта', header: 'Адрес объекта',
size: 250, size: 300,
minSize: 200, minSize: 200,
maxSize: 350, maxSize: 500,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>, Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false, enableColumnActions: false,
enableSorting: false, enableSorting: false,
@ -128,9 +92,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'approved_format', accessorKey: 'approved_format',
header: 'Утверждённый формат', header: 'Утверждённый формат',
size: 150, size: 190,
minSize: 120, minSize: 160,
maxSize: 180, maxSize: 240,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>, Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false, enableColumnActions: false,
enableSorting: false, enableSorting: false,
@ -138,9 +102,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'open_date', accessorKey: 'open_date',
header: 'Дата открытия ВСП', header: 'Дата открытия ВСП',
size: 130, size: 180,
minSize: 110, minSize: 150,
maxSize: 150, maxSize: 220,
Cell: ({ cell }) => { Cell: ({ cell }) => {
const date = cell.getValue(); const date = cell.getValue();
return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>; return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>;
@ -151,9 +115,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'close_date', accessorKey: 'close_date',
header: 'Дата закрытия ВСП', header: 'Дата закрытия ВСП',
size: 130, size: 180,
minSize: 110, minSize: 150,
maxSize: 150, maxSize: 220,
Cell: ({ cell }) => { Cell: ({ cell }) => {
const date = cell.getValue(); const date = cell.getValue();
return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>; return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>;
@ -164,9 +128,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'placement_type', accessorKey: 'placement_type',
header: 'Форма расположения ВСП', header: 'Форма расположения ВСП',
size: 170, size: 220,
minSize: 140, minSize: 180,
maxSize: 200, maxSize: 260,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>, Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false, enableColumnActions: false,
enableSorting: false, enableSorting: false,
@ -174,9 +138,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'staff_count', accessorKey: 'staff_count',
header: 'Штатная численность ВСП', header: 'Штатная численность ВСП',
size: 170, size: 230,
minSize: 140, minSize: 190,
maxSize: 200, maxSize: 270,
Cell: ({ cell }) => { Cell: ({ cell }) => {
const value = cell.getValue(); const value = cell.getValue();
return <Typography variant='body2'>{value !== undefined && value !== null ? value : '-'}</Typography>; return <Typography variant='body2'>{value !== undefined && value !== null ? value : '-'}</Typography>;
@ -197,9 +161,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'rent_contract_num', accessorKey: 'rent_contract_num',
header: 'Номер договора', header: 'Номер договора',
size: 130, size: 150,
minSize: 110, minSize: 130,
maxSize: 160, maxSize: 200,
Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>, Cell: ({ cell }) => <Typography variant='body2'>{cell.getValue() || '-'}</Typography>,
enableColumnActions: false, enableColumnActions: false,
enableSorting: false, enableSorting: false,
@ -207,9 +171,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'rent_end_date', accessorKey: 'rent_end_date',
header: 'Дата окончания договора', header: 'Дата окончания договора',
size: 150, size: 230,
minSize: 130, minSize: 190,
maxSize: 170, maxSize: 270,
Cell: ({ cell }) => { Cell: ({ cell }) => {
const date = cell.getValue(); const date = cell.getValue();
return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>; return <Typography variant='body2'>{date ? new Date(date).toLocaleDateString('ru-RU') : '-'}</Typography>;
@ -220,9 +184,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
{ {
accessorKey: 'notes', accessorKey: 'notes',
header: 'Примечания', header: 'Примечания',
size: 200, size: 300,
minSize: 150, minSize: 200,
maxSize: 300, maxSize: 500,
Cell: ({ cell }) => ( Cell: ({ cell }) => (
<Typography variant='body2' sx={{ whiteSpace: 'pre-wrap' }}> <Typography variant='body2' sx={{ whiteSpace: 'pre-wrap' }}>
{cell.getValue() || '-'} {cell.getValue() || '-'}
@ -245,6 +209,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
enableGlobalFilter: false, enableGlobalFilter: false,
enableSorting: false, enableSorting: false,
enablePagination: false, enablePagination: false,
enableRowVirtualization: true,
// Высоту строк MRT измеряет сам (по умолчанию): «Примечания» многострочные (pre-wrap).
rowVirtualizerOptions: { overscan: 5, estimateSize: () => ROW_ESTIMATED_HEIGHT },
positionActionsColumn: 'last', positionActionsColumn: 'last',
// Включаем изменение размера колонок для лучшего UX при горизонтальном скролле // Включаем изменение размера колонок для лучшего UX при горизонтальном скролле
@ -279,8 +246,8 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
Справочник объектов Справочник объектов
</Typography> </Typography>
<Stack spacing={'.5rem'} direction={'row'}> <Stack spacing={'.5rem'} direction={'row'}>
<PrimaryOutlinedButton onClick={handleExport}>Экспортировать</PrimaryOutlinedButton> <PrimaryOutlinedButton onClick={onExportClick}>Экспортировать</PrimaryOutlinedButton>
{user.role_id == ROLES_NAME_ID.admin && <PrimaryButton onClick={handleAdd}>+ Добавить объект</PrimaryButton>} {user.role_id == ROLES_NAME_ID.admin && <PrimaryButton onClick={onAddClick}>+ Добавить объект</PrimaryButton>}
</Stack> </Stack>
</Box> </Box>
), ),
@ -325,7 +292,7 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
}}> }}>
<IconButton <IconButton
size='small' size='small'
onClick={() => handleEdit(row.original)} onClick={() => onEditClick(row.original)}
sx={{ sx={{
padding: '4px', padding: '4px',
'&:hover': { '&:hover': {
@ -479,23 +446,10 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se
}); });
return ( return (
<>
<Box sx={{ position: 'relative', width: '100%' }}> <Box sx={{ position: 'relative', width: '100%' }}>
<MaterialReactTable table={table} /> <MaterialReactTable table={table} />
</Box> </Box>
<ModalEditAddVsp
open={modalOpen}
onClose={() => setModalOpen(false)}
onSave={handleSave}
onDelete={onDelete}
initialData={editingItem}
loading={saveLoading}
ssps={ssps}
/>
<ModalExport open={modalExportOpen} onClose={() => setModalExportOpen(false)} ssps={ssps} />
</>
); );
}; };
export default TableDictVsp; export default memo(TableDictVsp);