diff --git a/web/src/pages/DictVspPage/DictVspPage.jsx b/web/src/pages/DictVspPage/DictVspPage.jsx index 50d07b8..d9b1992 100644 --- a/web/src/pages/DictVspPage/DictVspPage.jsx +++ b/web/src/pages/DictVspPage/DictVspPage.jsx @@ -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 { Box, CircularProgress, Stack } from '@mui/material'; @@ -11,6 +11,7 @@ import { HeaderPortal } from '../../components/common/HeaderPortal'; import { ROLES_NAME_ID } from '../../constants/constants'; import { PageContainer } from '../../components/common/PageContainer'; import DictVspFilters from './components/DictVspFilters'; +import DictVspModals from './components/DictVspModals'; import TableDictVsp from './components/TableDictVsp'; const DictVspInfo = () => { @@ -30,6 +31,7 @@ function DictVspPage() { const [ssps, setSsps] = useState([]); const [selectedSsp, setSelectedSsp] = useState(null); const { user } = useAuth(); + const modalsRef = useRef(null); const loadData = useCallback(async () => { if (!user) return; @@ -68,7 +70,7 @@ function DictVspPage() { loadData(); }, [loadData]); - const handleAdd = async (formData) => { + const handleAdd = useCallback(async (formData) => { try { const response = await DictVspApi.create(formData); if (response.success) { @@ -82,9 +84,9 @@ function DictVspPage() { toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); throw error; } - }; + }, []); - const handleEdit = async (formData) => { + const handleEdit = useCallback(async (formData) => { try { const response = await DictVspApi.update(formData.id, formData); if (response.success) { @@ -98,9 +100,9 @@ function DictVspPage() { toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); throw error; } - }; + }, []); - const handleDelete = async (vspId) => { + const handleDelete = useCallback(async (vspId) => { try { const response = await DictVspApi.delete(vspId); if (response.success) { @@ -114,7 +116,11 @@ function DictVspPage() { toast.error(error.response?.data?.message || 'Ошибка удаления ВСП'); throw error; } - }; + }, []); + + const handleAddClick = useCallback(() => modalsRef.current?.openAdd(), []); + const handleEditClick = useCallback((item) => modalsRef.current?.openEdit(item), []); + const handleExportClick = useCallback(() => modalsRef.current?.openExport(), []); if (isLoading) { return ( @@ -139,12 +145,18 @@ function DictVspPage() { )} + diff --git a/web/src/pages/DictVspPage/components/DictVspModals.jsx b/web/src/pages/DictVspPage/components/DictVspModals.jsx new file mode 100644 index 0000000..22b7b01 --- /dev/null +++ b/web/src/pages/DictVspPage/components/DictVspModals.jsx @@ -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 ( + <> + + + + ); +}); + +DictVspModals.displayName = 'DictVspModals'; + +export default DictVspModals; diff --git a/web/src/pages/DictVspPage/components/TableDictVsp.jsx b/web/src/pages/DictVspPage/components/TableDictVsp.jsx index 9796933..1c96868 100644 --- a/web/src/pages/DictVspPage/components/TableDictVsp.jsx +++ b/web/src/pages/DictVspPage/components/TableDictVsp.jsx @@ -1,55 +1,19 @@ import { Box, IconButton, Stack, Typography } from '@mui/material'; 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 { PrimaryButton, PrimaryOutlinedButton } from '../../../components/common/Buttons/Buttons'; import { EditPenSvg } from '../../../components/common/icons/icons'; import { ROLES_NAME_ID } from '../../../constants/constants'; -import ModalEditAddVsp from './Modals/ModalEditAddVsp'; -import ModalExport from './Modals/ModalExport'; +import { filterVspBySsp } from '../utils/filterVspBySsp'; -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 [modalOpen, setModalOpen] = useState(false); - const [modalExportOpen, setModalExportOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [saveLoading, setSaveLoading] = useState(false); const tableContainerRef = useRef(null); - const filteredData = useMemo(() => { - 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 filteredData = useMemo(() => filterVspBySsp(data, selectedSsp), [data, selectedSsp]); const columns = useMemo( () => [ @@ -70,7 +34,7 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se { accessorKey: 'system_code', header: 'Код РФ', - size: 180, + size: 100, minSize: 50, maxSize: 100, Cell: ({ cell }) => ( @@ -98,9 +62,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se { accessorKey: 'regional_branch', header: 'Региональный филиал/ССП', - size: 120, + size: 186, minSize: 100, - maxSize: 150, + maxSize: 200, Cell: ({ cell }) => {cell.getValue() || '-'}, enableColumnActions: false, enableSorting: false, @@ -128,7 +92,7 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se { accessorKey: 'approved_format', header: 'Утверждённый формат', - size: 150, + size: 159, minSize: 120, maxSize: 180, Cell: ({ cell }) => {cell.getValue() || '-'}, @@ -138,7 +102,7 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se { accessorKey: 'open_date', header: 'Дата открытия ВСП', - size: 130, + size: 138, minSize: 110, maxSize: 150, Cell: ({ cell }) => { @@ -151,7 +115,7 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se { accessorKey: 'close_date', header: 'Дата закрытия ВСП', - size: 130, + size: 137, minSize: 110, maxSize: 150, Cell: ({ cell }) => { @@ -245,6 +209,9 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se enableGlobalFilter: false, enableSorting: false, enablePagination: false, + enableRowVirtualization: true, + // Высоту строк MRT измеряет сам (по умолчанию): «Примечания» многострочные (pre-wrap). + rowVirtualizerOptions: { overscan: 5, estimateSize: () => ROW_ESTIMATED_HEIGHT }, positionActionsColumn: 'last', // Включаем изменение размера колонок для лучшего UX при горизонтальном скролле @@ -279,8 +246,8 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se Справочник объектов - Экспортировать - {user.role_id == ROLES_NAME_ID.admin && + Добавить объект} + Экспортировать + {user.role_id == ROLES_NAME_ID.admin && + Добавить объект} ), @@ -307,6 +274,8 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se size: 80, muiTableHeadCellProps: { align: 'center', + // В grid-раскладке th — колонка flex, align center иначе центрирует заголовок по вертикали. + sx: { justifyContent: 'flex-start' }, }, muiTableBodyCellProps: { align: 'center', @@ -325,7 +294,7 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se }}> handleEdit(row.original)} + onClick={() => onEditClick(row.original)} sx={{ padding: '4px', '&:hover': { @@ -479,23 +448,10 @@ const TableDictVsp = ({ data, onEdit, onDelete, onAdd, loading = false, ssps, se }); return ( - <> - - - - - setModalOpen(false)} - onSave={handleSave} - onDelete={onDelete} - initialData={editingItem} - loading={saveLoading} - ssps={ssps} - /> - setModalExportOpen(false)} ssps={ssps} /> - + + + ); }; -export default TableDictVsp; +export default memo(TableDictVsp); diff --git a/web/src/pages/DictVspPage/utils/filterVspBySsp.js b/web/src/pages/DictVspPage/utils/filterVspBySsp.js new file mode 100644 index 0000000..8a8eb12 --- /dev/null +++ b/web/src/pages/DictVspPage/utils/filterVspBySsp.js @@ -0,0 +1,6 @@ +export const filterVspBySsp = (data, selectedSsp) => { + if (!selectedSsp) { + return data; + } + return data.filter((item) => item.ssp_id === selectedSsp.id); +}; diff --git a/web/src/pages/DictVspPage/utils/filterVspBySsp.test.js b/web/src/pages/DictVspPage/utils/filterVspBySsp.test.js new file mode 100644 index 0000000..59417f5 --- /dev/null +++ b/web/src/pages/DictVspPage/utils/filterVspBySsp.test.js @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { filterVspBySsp } from './filterVspBySsp.js'; + +const data = [ + { id: 1, ssp_id: 10 }, + { id: 2, ssp_id: 20 }, + { id: 3, ssp_id: 10 }, +]; + +test('без выбранного ССП возвращает те же данные (ссылка не меняется)', () => { + assert.equal(filterVspBySsp(data, null), data); + assert.equal(filterVspBySsp(data, undefined), data); +}); + +test('с выбранным ССП оставляет только его ВСП, порядок сохраняется', () => { + assert.deepEqual( + filterVspBySsp(data, { id: 10 }).map((v) => v.id), + [1, 3], + ); +}); + +test('ССП без ВСП даёт пустой список', () => { + assert.deepEqual(filterVspBySsp(data, { id: 99 }), []); +});