feat(vsp): справочник ВСП — статус «Действующие/Закрытые/Все», период закрытия, бейдж и выгрузка по фильтру (AURORA-1883, #330)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a561939a66
commit
c39bbbfd06
@ -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,12 +93,15 @@ function DictVspPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleAdd = useCallback(async (formData) => {
|
||||
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 || 'Ошибка создания ВСП');
|
||||
}
|
||||
@ -84,14 +110,21 @@ function DictVspPage() {
|
||||
toast.error(error.response?.data?.message || 'Ошибка создания ВСП');
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[status],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(async (formData) => {
|
||||
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]);
|
||||
// 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 || 'Ошибка обновления ВСП');
|
||||
}
|
||||
@ -100,7 +133,9 @@ function DictVspPage() {
|
||||
toast.error(error.response?.data?.message || 'Ошибка обновления ВСП');
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[status],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(async (vspId) => {
|
||||
try {
|
||||
@ -140,16 +175,24 @@ function DictVspPage() {
|
||||
<PageContainer>
|
||||
<Stack sx={{ alignItems: 'stretch', width: '100%' }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{user.role_id !== ROLES_NAME_ID.executor_rf && (
|
||||
<DictVspFilters ssps={ssps} onSspChange={setSelectedSsp} selectedSsp={selectedSsp} />
|
||||
)}
|
||||
<DictVspFilters
|
||||
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
|
||||
data={dictVspData}
|
||||
selectedSsp={selectedSsp}
|
||||
onAddClick={handleAddClick}
|
||||
onEditClick={handleEditClick}
|
||||
onExportClick={handleExportClick}
|
||||
loading={isLoading}
|
||||
loading={isLoading || isListLoading}
|
||||
/>
|
||||
<DictVspModals
|
||||
ref={modalsRef}
|
||||
@ -157,6 +200,7 @@ function DictVspPage() {
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
listFilter={listFilter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@ -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 (
|
||||
<Paper
|
||||
sx={{
|
||||
@ -37,7 +53,8 @@ const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => {
|
||||
gap: '1rem',
|
||||
mb: '1.5rem',
|
||||
}}>
|
||||
<Stack direction={'row'} spacing={'1rem'}>
|
||||
<Stack direction={'row'} spacing={'1rem'} sx={{ alignItems: 'center', flexWrap: 'wrap', rowGap: '1rem' }}>
|
||||
{showSsp && (
|
||||
<Box sx={{ width: '30rem' }}>
|
||||
<AutocompleteStyled
|
||||
size='small'
|
||||
@ -50,6 +67,42 @@ const DictVspFilters = ({ ssps, selectedSsp = null, onSspChange }) => {
|
||||
renderInput={(params) => <TextField {...params} placeholder='Все ССП/РФ' />}
|
||||
/>
|
||||
</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>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
<ModalExport open={modalExportOpen} onClose={closeExport} ssps={ssps} />
|
||||
<ModalExport open={modalExportOpen} onClose={closeExport} ssps={ssps} listFilter={listFilter} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
<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',
|
||||
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: {
|
||||
|
||||
23
web/src/pages/DictVspPage/utils/vspListParams.js
Normal file
23
web/src/pages/DictVspPage/utils/vspListParams.js
Normal 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;
|
||||
}
|
||||
33
web/src/pages/DictVspPage/utils/vspListParams.test.js
Normal file
33
web/src/pages/DictVspPage/utils/vspListParams.test.js
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user