feat(vsp): справочник ВСП — статус «Действующие/Закрытые/Все», период закрытия, бейдж и выгрузка по фильтру (AURORA-1883, #330)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-12 11:52:18 +04:00
parent a561939a66
commit c39bbbfd06
7 changed files with 251 additions and 64 deletions

View File

@ -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 { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
import { Box, CircularProgress, Stack } from '@mui/material'; import { Box, CircularProgress, Stack } from '@mui/material';
@ -13,6 +13,7 @@ import { PageContainer } from '../../components/common/PageContainer';
import DictVspFilters from './components/DictVspFilters'; import DictVspFilters from './components/DictVspFilters';
import DictVspModals from './components/DictVspModals'; import DictVspModals from './components/DictVspModals';
import TableDictVsp from './components/TableDictVsp'; import TableDictVsp from './components/TableDictVsp';
import { buildVspListParams, vspMatchesStatus } from './utils/vspListParams';
const DictVspInfo = () => { const DictVspInfo = () => {
const portalContent = ( const portalContent = (
@ -30,24 +31,46 @@ function DictVspPage() {
const [dictVspData, setDictVspData] = useState([]); const [dictVspData, setDictVspData] = useState([]);
const [ssps, setSsps] = useState([]); const [ssps, setSsps] = useState([]);
const [selectedSsp, setSelectedSsp] = useState(null); 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 { user } = useAuth();
const modalsRef = useRef(null); 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 () => { const loadData = useCallback(async () => {
if (!user) return; if (!user) return;
setIsLoading(true); setIsLoading(true);
try { try {
const isAdmin = user.role_id === ROLES_NAME_ID.admin; const isAdmin = user.role_id === ROLES_NAME_ID.admin;
const [vspResponse, sspResponse] = await Promise.all([ const sspResponse = isAdmin ? await SspApi.getAll({ is_active: true }) : null;
DictVspApi.get(),
isAdmin ? SspApi.getAll({ is_active: true }) : Promise.resolve(null),
]);
if (!vspResponse.success) {
toast.error('Ошибка загрузки справочника');
return;
}
setDictVspData(vspResponse.result || []);
if (isAdmin) { if (isAdmin) {
if (!sspResponse.success) { if (!sspResponse.success) {
@ -70,37 +93,49 @@ function DictVspPage() {
loadData(); loadData();
}, [loadData]); }, [loadData]);
const handleAdd = useCallback(async (formData) => { const handleAdd = useCallback(
try { async (formData) => {
const response = await DictVspApi.create(formData); try {
if (response.success) { const response = await DictVspApi.create(formData);
toast.success('ВСП успешно создан'); if (response.success) {
setDictVspData((prev) => [...prev, response.result]); toast.success('ВСП успешно создан');
} else { if (vspMatchesStatus(response.result, status)) {
toast.error(response.message || 'Ошибка создания ВСП'); 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); [status],
toast.error(error.response?.data?.message || 'Ошибка создания ВСП'); );
throw error;
}
}, []);
const handleEdit = useCallback(async (formData) => { const handleEdit = useCallback(
try { async (formData) => {
const response = await DictVspApi.update(formData.id, formData); try {
if (response.success) { const response = await DictVspApi.update(formData.id, formData);
toast.success('ВСП успешно обновлен'); if (response.success) {
setDictVspData((prev) => [...prev.filter((v) => v.id !== formData.id), response.result]); toast.success('ВСП успешно обновлен');
} else { // AURORA-1883: ВСП, переставший подходить под статус (например, закрыт в «Действующих»), уходит из списка.
toast.error(response.message || 'Ошибка обновления ВСП'); 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); [status],
toast.error(error.response?.data?.message || 'Ошибка обновления ВСП'); );
throw error;
}
}, []);
const handleDelete = useCallback(async (vspId) => { const handleDelete = useCallback(async (vspId) => {
try { try {
@ -140,16 +175,24 @@ function DictVspPage() {
<PageContainer> <PageContainer>
<Stack sx={{ alignItems: 'stretch', width: '100%' }}> <Stack sx={{ alignItems: 'stretch', width: '100%' }}>
<Box sx={{ width: '100%' }}> <Box sx={{ width: '100%' }}>
{user.role_id !== ROLES_NAME_ID.executor_rf && ( <DictVspFilters
<DictVspFilters ssps={ssps} onSspChange={setSelectedSsp} selectedSsp={selectedSsp} /> ssps={ssps}
)} onSspChange={setSelectedSsp}
selectedSsp={selectedSsp}
showSsp={user.role_id !== ROLES_NAME_ID.executor_rf}
status={status}
onStatusChange={setStatus}
closeDateStart={closeDates.closeDateStart}
closeDateEnd={closeDates.closeDateEnd}
onCloseDateChange={setCloseDates}
/>
<TableDictVsp <TableDictVsp
data={dictVspData} data={dictVspData}
selectedSsp={selectedSsp} selectedSsp={selectedSsp}
onAddClick={handleAddClick} onAddClick={handleAddClick}
onEditClick={handleEditClick} onEditClick={handleEditClick}
onExportClick={handleExportClick} onExportClick={handleExportClick}
loading={isLoading} loading={isLoading || isListLoading}
/> />
<DictVspModals <DictVspModals
ref={modalsRef} ref={modalsRef}
@ -157,6 +200,7 @@ function DictVspPage() {
onAdd={handleAdd} onAdd={handleAdd}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
listFilter={listFilter}
/> />
</Box> </Box>
</Stack> </Stack>

View File

@ -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 { styled } from '@mui/material';
import { VSP_STATUS_OPTIONS } from '../utils/vspListParams';
const AutocompleteStyled = styled(Autocomplete)` const AutocompleteStyled = styled(Autocomplete)`
& .MuiOutlinedInput-root { & .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 ( return (
<Paper <Paper
sx={{ sx={{
@ -37,19 +53,56 @@ const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => {
gap: '1rem', gap: '1rem',
mb: '1.5rem', mb: '1.5rem',
}}> }}>
<Stack direction={'row'} spacing={'1rem'}> <Stack direction={'row'} spacing={'1rem'} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: '1rem' }}>
<Box sx={{ width: '30rem' }}> {showSsp && (
<AutocompleteStyled <Box sx={{ width: '30rem' }}>
size='small' <AutocompleteStyled
options={ssps || []} size='small'
noOptionsText='Не найдено' options={ssps || []}
getOptionLabel={(option) => option.title || option.name || ''} noOptionsText='Не найдено'
value={selectedSsp} getOptionLabel={(option) => option.title || option.name || ''}
getOptionKey={(option) => option.id || option.name || option.title} value={selectedSsp}
onChange={(event, newValue) => onSspChange(newValue)} getOptionKey={(option) => option.id || option.name || option.title}
renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />} onChange={(event, newValue) => onSspChange(newValue)}
/> renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />}
</Box> />
</Box>
)}
{/* AURORA-1883: статус ВСП и период закрытия */}
<ToggleButtonGroup
exclusive
size='small'
value={status}
onChange={(_event, value) => value && onStatusChange?.(value)}
sx={{ backgroundColor: 'white', height: '2.56rem' }}>
{VSP_STATUS_OPTIONS.map((option) => (
<ToggleButton key={option.value} value={option.value} sx={{ textTransform: 'none', px: '1rem' }}>
{option.label}
</ToggleButton>
))}
</ToggleButtonGroup>
{status !== 'active' && (
<>
<TextField
type='date'
size='small'
label='Закрыт с'
value={closeDateStart}
onChange={(event) => onCloseDateChange?.({ closeDateStart: event.target.value, closeDateEnd })}
InputLabelProps={{ shrink: true }}
sx={DATE_INPUT_SX}
/>
<TextField
type='date'
size='small'
label='по'
value={closeDateEnd}
onChange={(event) => onCloseDateChange?.({ closeDateStart, closeDateEnd: event.target.value })}
InputLabelProps={{ shrink: true }}
sx={DATE_INPUT_SX}
/>
</>
)}
</Stack> </Stack>
</Paper> </Paper>
); );

View File

@ -3,7 +3,7 @@ import ModalEditAddVsp from './Modals/ModalEditAddVsp';
import ModalExport from './Modals/ModalExport'; import ModalExport from './Modals/ModalExport';
// Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT. // Состояние модалок живёт здесь, а не в таблице: открытие/закрытие не перерисовывает MRT.
const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => { const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps, listFilter }, ref) => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modalExportOpen, setModalExportOpen] = useState(false); const [modalExportOpen, setModalExportOpen] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [editingItem, setEditingItem] = useState(null);
@ -55,7 +55,7 @@ const DictVspModals = forwardRef(({ onAdd, onEdit, onDelete, ssps }, ref) => {
loading={saveLoading} loading={saveLoading}
ssps={ssps} ssps={ssps}
/> />
<ModalExport open={modalExportOpen} onClose={closeExport} ssps={ssps} /> <ModalExport open={modalExportOpen} onClose={closeExport} ssps={ssps} listFilter={listFilter} />
</> </>
); );
}); });

View File

@ -7,9 +7,10 @@ import Checkbox from '../../../../components/common/Checkbox';
import Modal from '../../../../components/common/Modal/Modal'; import Modal from '../../../../components/common/Modal/Modal';
import SearchComponent from '../../../../components/common/SearchComponent'; import SearchComponent from '../../../../components/common/SearchComponent';
import { downloadFile } from '../../../../utils/downloadFile'; import { downloadFile } from '../../../../utils/downloadFile';
import { buildVspListParams } from '../../utils/vspListParams';
import { ModalContainer } from './Modal.style'; 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 [searchTerm, setSearchTerm] = useState('');
const [selectedSsps, setSelectedSsps] = useState([]); const [selectedSsps, setSelectedSsps] = useState([]);
@ -45,8 +46,10 @@ const ModalExport = ({ open, onClose, ssps = [], loading = false }) => {
const handleExport = async () => { const handleExport = async () => {
if (selectedSsps.length > 0) { if (selectedSsps.length > 0) {
try { try {
// AURORA-1883: выгрузка по текущему статусу и периоду закрытия справочника.
const response = await DictVspApi.export({ const response = await DictVspApi.export({
ssp_ids: selectedSsps, ssp_ids: selectedSsps,
...buildVspListParams(listFilter),
}); });
await downloadFile(response, 'Справочник ВСП', 'csv'); await downloadFile(response, 'Справочник ВСП', 'csv');
} catch (error) { } catch (error) {

View File

@ -125,6 +125,36 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading =
enableColumnActions: false, enableColumnActions: false,
enableSorting: 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 (
<Box
component='span'
sx={{
border: isActive ? '.0625rem solid #bedbff' : '.0625rem solid rgba(0, 0, 0, 0.1)',
borderRadius: '0.5rem',
padding: '0.12rem 0.5rem',
background: isActive ? '#e8f2ff' : '#f3f4f6',
color: isActive ? '#155dfc' : '#99a1af',
fontSize: '0.75rem',
display: 'inline-flex',
alignItems: 'center',
width: 'max-content',
}}>
{isActive ? 'Действует' : 'Закрыт'}
</Box>
);
},
enableColumnActions: false,
enableSorting: false,
},
{ {
accessorKey: 'placement_type', accessorKey: 'placement_type',
header: 'Форма расположения ВСП', header: 'Форма расположения ВСП',
@ -412,15 +442,16 @@ const TableDictVsp = ({ data, onEditClick, onAddClick, onExportClick, loading =
}, },
}, },
muiTableBodyCellProps: { muiTableBodyCellProps: ({ row }) => ({
sx: { sx: {
padding: '0.75rem', padding: '0.75rem',
fontSize: '0.75rem', fontSize: '0.75rem',
lineHeight: '150%', lineHeight: '150%',
color: '#364153', // AURORA-1883: текст строк закрытых ВСП приглушён.
color: row.original.is_active === false ? '#99a1af' : '#364153',
whiteSpace: 'nowrap', // Предотвращаем перенос текста whiteSpace: 'nowrap', // Предотвращаем перенос текста
}, },
}, }),
muiTableHeadCellProps: { muiTableHeadCellProps: {
sx: { sx: {

View File

@ -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;
}

View File

@ -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);
});