diff --git a/web/src/pages/DictVspPage/DictVspPage.jsx b/web/src/pages/DictVspPage/DictVspPage.jsx index d9b1992..c6f6e0d 100644 --- a/web/src/pages/DictVspPage/DictVspPage.jsx +++ b/web/src/pages/DictVspPage/DictVspPage.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style'; import { Box, CircularProgress, Stack } from '@mui/material'; @@ -13,6 +13,7 @@ import { PageContainer } from '../../components/common/PageContainer'; import DictVspFilters from './components/DictVspFilters'; import DictVspModals from './components/DictVspModals'; import TableDictVsp from './components/TableDictVsp'; +import { buildVspListParams, vspMatchesStatus } from './utils/vspListParams'; const DictVspInfo = () => { const portalContent = ( @@ -30,24 +31,46 @@ function DictVspPage() { const [dictVspData, setDictVspData] = useState([]); const [ssps, setSsps] = useState([]); const [selectedSsp, setSelectedSsp] = useState(null); + // AURORA-1883: статус ВСП и период закрытия; список ВСП перезагружается с сервера при их смене. + const [status, setStatus] = useState('active'); + const [closeDates, setCloseDates] = useState({ closeDateStart: '', closeDateEnd: '' }); + const [isListLoading, setIsListLoading] = useState(true); + const listRequestRef = useRef(0); const { user } = useAuth(); const modalsRef = useRef(null); + const listFilter = useMemo(() => ({ status, ...closeDates }), [status, closeDates]); + + const loadVspList = useCallback(async () => { + if (!user) return; + const requestId = ++listRequestRef.current; + setIsListLoading(true); + try { + const vspResponse = await DictVspApi.get(buildVspListParams(listFilter)); + if (requestId !== listRequestRef.current) return; + if (!vspResponse.success) { + toast.error('Ошибка загрузки справочника'); + return; + } + setDictVspData(vspResponse.result || []); + } catch (error) { + if (requestId !== listRequestRef.current) return; + toast.error(error.response?.data?.message || 'Ошибка загрузки справочника'); + } finally { + if (requestId === listRequestRef.current) setIsListLoading(false); + } + }, [user, listFilter]); + + useEffect(() => { + loadVspList(); + }, [loadVspList]); + const loadData = useCallback(async () => { if (!user) return; setIsLoading(true); try { const isAdmin = user.role_id === ROLES_NAME_ID.admin; - const [vspResponse, sspResponse] = await Promise.all([ - DictVspApi.get(), - isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null), - ]); - - if (!vspResponse.success) { - toast.error('Ошибка загрузки справочника'); - return; - } - setDictVspData(vspResponse.result || []); + const sspResponse = isAdmin ? await SspApi.getAll({ is_active: true }) : null; if (isAdmin) { if (!sspResponse.success) { @@ -70,37 +93,49 @@ function DictVspPage() { loadData(); }, [loadData]); - const handleAdd = useCallback(async (formData) => { - try { - const response = await DictVspApi.create(formData); - if (response.success) { - toast.success('ВСП успешно создан'); - setDictVspData((prev) => [...prev, response.result]); - } else { - toast.error(response.message || 'Ошибка создания ВСП'); + const handleAdd = useCallback( + async (formData) => { + try { + const response = await DictVspApi.create(formData); + if (response.success) { + toast.success('ВСП успешно создан'); + if (vspMatchesStatus(response.result, status)) { + setDictVspData((prev) => [...prev, response.result]); + } + } else { + toast.error(response.message || 'Ошибка создания ВСП'); + } + } catch (error) { + console.error('Error adding VSP:', error); + toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); + throw error; } - } catch (error) { - console.error('Error adding VSP:', error); - toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); - throw error; - } - }, []); + }, + [status], + ); - const handleEdit = useCallback(async (formData) => { - try { - const response = await DictVspApi.update(formData.id, formData); - if (response.success) { - toast.success('ВСП успешно обновлен'); - setDictVspData((prev) => [...prev.filter((v) => v.id !== formData.id), response.result]); - } else { - toast.error(response.message || 'Ошибка обновления ВСП'); + const handleEdit = useCallback( + async (formData) => { + try { + const response = await DictVspApi.update(formData.id, formData); + if (response.success) { + toast.success('ВСП успешно обновлен'); + // AURORA-1883: ВСП, переставший подходить под статус (например, закрыт в «Действующих»), уходит из списка. + setDictVspData((prev) => { + const rest = prev.filter((v) => v.id !== formData.id); + return vspMatchesStatus(response.result, status) ? [...rest, response.result] : rest; + }); + } else { + toast.error(response.message || 'Ошибка обновления ВСП'); + } + } catch (error) { + console.error('Error editing VSP:', error); + toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); + throw error; } - } catch (error) { - console.error('Error editing VSP:', error); - toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); - throw error; - } - }, []); + }, + [status], + ); const handleDelete = useCallback(async (vspId) => { try { @@ -140,16 +175,24 @@ function DictVspPage() { - {user.role_id !== ROLES_NAME_ID.executor_rf && ( - - )} + diff --git a/web/src/pages/DictVspPage/components/DictVspFilters.jsx b/web/src/pages/DictVspPage/components/DictVspFilters.jsx index cec8f08..8e07ed7 100644 --- a/web/src/pages/DictVspPage/components/DictVspFilters.jsx +++ b/web/src/pages/DictVspPage/components/DictVspFilters.jsx @@ -1,5 +1,6 @@ -import { Autocomplete, Box, Paper, Stack, TextField } from '@mui/material'; +import { Autocomplete, Box, Paper, Stack, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material'; import { styled } from '@mui/material'; +import { VSP_STATUS_OPTIONS } from '../utils/vspListParams'; const AutocompleteStyled = styled(Autocomplete)` & .MuiOutlinedInput-root { @@ -23,7 +24,22 @@ const AutocompleteStyled = styled(Autocomplete)` } `; -const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => { +const DATE_INPUT_SX = { + width: '11rem', + '& .MuiOutlinedInput-root': { backgroundColor: 'white', height: '2.56rem', borderRadius: '0.63rem' }, +}; + +const DictVspFilters = ({ + ssps, + selectedSsp = null, + onSspChange, + showSsp = true, + status = 'active', + onStatusChange, + closeDateStart = '', + closeDateEnd = '', + onCloseDateChange, +}) => { return ( { gap: '1rem', mb: '1.5rem', }}> - - - option.title || option.name || ''} - value={selectedSsp} - getOptionKey={(option) => option.id || option.name || option.title} - onChange={(event, newValue) => onSspChange(newValue)} - renderInput={(params) => } - /> - + + {showSsp && ( + + option.title || option.name || ''} + value={selectedSsp} + getOptionKey={(option) => option.id || option.name || option.title} + onChange={(event, newValue) => onSspChange(newValue)} + renderInput={(params) => } + /> + + )} + {/* AURORA-1883: статус ВСП и период закрытия */} + value && onStatusChange?.(value)} + sx={{ backgroundColor: 'white', height: '2.56rem' }}> + {VSP_STATUS_OPTIONS.map((option) => ( + + {option.label} + + ))} + + {status !== 'active' && ( + <> + onCloseDateChange?.({ closeDateStart: event.target.value, closeDateEnd })} + InputLabelProps={{ shrink: true }} + sx={DATE_INPUT_SX} + /> + onCloseDateChange?.({ closeDateStart, closeDateEnd: event.target.value })} + InputLabelProps={{ shrink: true }} + sx={DATE_INPUT_SX} + /> + + )} ); diff --git a/web/src/pages/DictVspPage/components/DictVspModals.jsx b/web/src/pages/DictVspPage/components/DictVspModals.jsx index 22b7b01..8c94e14 100644 --- a/web/src/pages/DictVspPage/components/DictVspModals.jsx +++ b/web/src/pages/DictVspPage/components/DictVspModals.jsx @@ -3,7 +3,7 @@ import ModalEditAddVsp from './Modals/ModalEditAddVsp'; import ModalExport from './Modals/ModalExport'; // Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT. -const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => { +const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps, listFilter }, ref) => { const [modalOpen, setModalOpen] = useState(false); const [modalExportOpen, setModalExportOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); @@ -55,7 +55,7 @@ const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => { loading={saveLoading} ssps={ssps} /> - + ); }); diff --git a/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx b/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx index 47c46cb..0887031 100644 --- a/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx +++ b/web/src/pages/DictVspPage/components/Modals/ModalExport.jsx @@ -7,9 +7,10 @@ import Checkbox from '../../../../components/common/Checkbox'; import Modal from '../../../../components/common/Modal/Modal'; import SearchComponent from '../../../../components/common/SearchComponent'; import { downloadFile } from '../../../../utils/downloadFile'; +import { buildVspListParams } from '../../utils/vspListParams'; import { ModalContainer } from './Modal.style'; -const ModalExport = ({ open, onClose, ssps = [], loading = false }) => { +const ModalExport = ({ open, onClose, ssps = [], loading = false, listFilter = { status: 'active' } }) => { const [searchTerm, setSearchTerm] = useState(''); const [selectedSsps, setSelectedSsps] = useState([]); @@ -45,8 +46,10 @@ const ModalExport = ({ open, onClose, ssps = [], loading = false }) => { const handleExport = async () => { if (selectedSsps.length > 0) { try { + // AURORA-1883: выгрузка по текущему статусу и периоду закрытия справочника. const response = await DictVspApi.export({ ssp_ids: selectedSsps, + ...buildVspListParams(listFilter), }); await downloadFile(response, 'Справочник ВСП', 'csv'); } catch (error) { diff --git a/web/src/pages/DictVspPage/components/TableDictVsp.jsx b/web/src/pages/DictVspPage/components/TableDictVsp.jsx index 1c96868..792cded 100644 --- a/web/src/pages/DictVspPage/components/TableDictVsp.jsx +++ b/web/src/pages/DictVspPage/components/TableDictVsp.jsx @@ -125,6 +125,36 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading = enableColumnActions: false, enableSorting: false, }, + { + // AURORA-1883: статус ВСП — как бейдж «Активен»/«Архив» в SspTable. + accessorKey: 'is_active', + header: 'Статус', + size: 110, + minSize: 100, + maxSize: 130, + Cell: ({ row }) => { + const isActive = row.original.is_active !== false; + return ( + + {isActive ? 'Действует' : 'Закрыт'} + + ); + }, + enableColumnActions: false, + enableSorting: false, + }, { accessorKey: 'placement_type', header: 'Форма расположения ВСП', @@ -412,15 +442,16 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading = }, }, - muiTableBodyCellProps: { + muiTableBodyCellProps: ({ row }) => ({ sx: { padding: '0.75rem', fontSize: '0.75rem', lineHeight: '150%', - color: '#364153', + // AURORA-1883: текст строк закрытых ВСП приглушён. + color: row.original.is_active === false ? '#99a1af' : '#364153', whiteSpace: 'nowrap', // Предотвращаем перенос текста }, - }, + }), muiTableHeadCellProps: { sx: { diff --git a/web/src/pages/DictVspPage/utils/vspListParams.js b/web/src/pages/DictVspPage/utils/vspListParams.js new file mode 100644 index 0000000..fcbc40c --- /dev/null +++ b/web/src/pages/DictVspPage/utils/vspListParams.js @@ -0,0 +1,23 @@ +// AURORA-1883: статус справочника ВСП — 'active' (по умолчанию) | 'closed' | 'all'. + +export const VSP_STATUS_OPTIONS = [ + { value: 'active', label: 'Действующие' }, + { value: 'closed', label: 'Закрытые' }, + { value: 'all', label: 'Все' }, +]; + +/** Параметры GET /dict/info и тела выгрузки: период закрытия — только для «Закрытые»/«Все». */ +export function buildVspListParams({ status, closeDateStart, closeDateEnd }) { + const params = { status }; + if (status === 'active') return params; + if (closeDateStart) params.close_date_start = closeDateStart; + if (closeDateEnd) params.close_date_end = closeDateEnd; + return params; +} + +/** Подходит ли ВСП под выбранный статус (после правки строка может выпасть из списка). */ +export function vspMatchesStatus(vsp, status) { + if (status === 'active') return vsp.is_active !== false; + if (status === 'closed') return vsp.is_active === false; + return true; +} diff --git a/web/src/pages/DictVspPage/utils/vspListParams.test.js b/web/src/pages/DictVspPage/utils/vspListParams.test.js new file mode 100644 index 0000000..330bba3 --- /dev/null +++ b/web/src/pages/DictVspPage/utils/vspListParams.test.js @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildVspListParams, vspMatchesStatus } from './vspListParams.js'; + +// AURORA-1883: параметры запроса справочника ВСП по статусу и периоду закрытия. +test('active status sends no close period', () => { + assert.deepEqual(buildVspListParams({ status: 'active', closeDateStart: '2021-01-01', closeDateEnd: '' }), { + status: 'active', + }); +}); + +test('closed status sends both close dates', () => { + assert.deepEqual(buildVspListParams({ status: 'closed', closeDateStart: '2021-01-01', closeDateEnd: '2021-12-31' }), { + status: 'closed', + close_date_start: '2021-01-01', + close_date_end: '2021-12-31', + }); +}); + +test('all status sends only the filled close date', () => { + assert.deepEqual(buildVspListParams({ status: 'all', closeDateStart: '', closeDateEnd: '2021-12-31' }), { + status: 'all', + close_date_end: '2021-12-31', + }); +}); + +test('vspMatchesStatus follows is_active', () => { + assert.equal(vspMatchesStatus({ is_active: true }, 'active'), true); + assert.equal(vspMatchesStatus({ is_active: false }, 'active'), false); + assert.equal(vspMatchesStatus({ is_active: false }, 'closed'), true); + assert.equal(vspMatchesStatus({ is_active: true }, 'closed'), false); + assert.equal(vspMatchesStatus({ is_active: false }, 'all'), true); +});