502 lines
16 KiB
JavaScript
502 lines
16 KiB
JavaScript
import { Autocomplete, Box, Button, FormControl, FormLabel, MenuItem, Select, TextField } from '@mui/material';
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { findItemPage, positiveInteger, readListVisit, rememberListVisit } from '../../utils/listNavigation';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import { toast } from 'react-toastify';
|
||
import { SspApi } from '../../api/ssp';
|
||
import { TasksApi } from '../../api/tasks';
|
||
import { useAuth } from '../../app/context/AuthProvider';
|
||
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
|
||
import { ResetFiltersButton } from '../../components/common/Buttons/ButtonsActions';
|
||
import Modal from '../../components/common/Modal/Modal';
|
||
import { OrgUnitsAutocompletePaper } from '../../components/common/OrgUnitsAutocompletePaper/OrgUnitsAutocompletePaper';
|
||
import { renderOrgUnitValue } from '../../components/common/OrgUnitsAutocompletePaper/renderOrgUnitValue';
|
||
import { PaginatedList } from '../../components/common/PaginatedList';
|
||
import { TaskCardComponent } from '../../components/common/TaskCardComponent';
|
||
import { WhitePlus } from '../../components/common/icons/icons';
|
||
import { FORM_TYPE_OPTIONS, FORM_TYPE_TRANSLATE, ORG_UNIT_TYPE_OPTIONS, ROLES_NAME_ID } from '../../constants/constants';
|
||
import { PageContainer } from '../StyledForPage';
|
||
|
||
const modalSelectMenuProps = {
|
||
sx: { zIndex: 1301 },
|
||
style: { zIndex: 1301 },
|
||
};
|
||
|
||
const modalSelectSx = {
|
||
'& .MuiOutlinedInput-notchedOutline': {
|
||
borderColor: 'rgba(0,0,0,0.1)',
|
||
},
|
||
};
|
||
|
||
export default function TasksPage() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
const [items, setItems] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [loadingUpdate, setLoadingUpdate] = useState(false);
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
|
||
const yearFilter = searchParams.get('year') || '';
|
||
const formTypeFilter = searchParams.get('form_type') || '';
|
||
const orgUnitsFilter = searchParams.get('org_units') || '';
|
||
const deleteStatusFilter = searchParams.get('delete_status') || 'all';
|
||
const orgUnitIdsFilter = useMemo(
|
||
() =>
|
||
orgUnitsFilter
|
||
.split(',')
|
||
.filter(Boolean)
|
||
.map(Number)
|
||
.filter((value) => Number.isInteger(value) && value > 0),
|
||
[orgUnitsFilter],
|
||
);
|
||
|
||
const page = positiveInteger(searchParams.get('page'), 1);
|
||
const limit = [15, 25, 50, 100, 200].includes(Number(searchParams.get('limit'))) ? Number(searchParams.get('limit')) : 25;
|
||
const [lastVisit] = useState(() => readListVisit('tasks'));
|
||
const listUrl = `/tasks${searchParams.size ? `?${searchParams}` : ''}`;
|
||
const restoreVisit = useRef(lastVisit?.url === listUrl ? lastVisit : null);
|
||
const requestId = useRef(0);
|
||
const setPage = (value) => setSearchParams((params) => {
|
||
const next = new URLSearchParams(params);
|
||
next.set('page', String(value));
|
||
return next;
|
||
});
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
|
||
const [sspList, setSspList] = useState([]);
|
||
const [formData, setFormData] = useState({
|
||
year: '',
|
||
orgUnitType: '',
|
||
org_unit_id: [],
|
||
formTypeCode: '',
|
||
});
|
||
|
||
const yearOptions = useMemo(() => {
|
||
const currentYear = new Date().getFullYear();
|
||
return Array.from({ length: 8 }, (_, index) => currentYear - 5 + index);
|
||
}, []);
|
||
|
||
const selectedOrgUnits = useMemo(() => sspList.filter((unit) => orgUnitIdsFilter.includes(unit.id)), [sspList, orgUnitIdsFilter]);
|
||
|
||
const filteredOrgUnits = useMemo(() => {
|
||
if (!formData.orgUnitType) return [];
|
||
const isSsp = formData.orgUnitType === 'ssp';
|
||
return sspList.filter((unit) => unit.is_ssp === isSsp);
|
||
}, [sspList, formData.orgUnitType]);
|
||
|
||
const isAllOrgUnitsSelected = useMemo(() => {
|
||
return filteredOrgUnits.length > 0 && filteredOrgUnits.every((unit) => formData.org_unit_id.includes(unit.id));
|
||
}, [filteredOrgUnits, formData.org_unit_id]);
|
||
|
||
const isOrgUnitsIndeterminate = useMemo(() => {
|
||
return formData.org_unit_id.length > 0 && !isAllOrgUnitsSelected;
|
||
}, [formData.org_unit_id, isAllOrgUnitsSelected]);
|
||
|
||
const { user } = useAuth();
|
||
|
||
const updateFilter = (name, value) => {
|
||
restoreVisit.current = null;
|
||
setSearchParams((currentParams) => {
|
||
const nextParams = new URLSearchParams(currentParams);
|
||
nextParams.set('page', '1');
|
||
if (value) {
|
||
nextParams.set(name, value);
|
||
} else {
|
||
nextParams.delete(name);
|
||
}
|
||
return nextParams;
|
||
});
|
||
};
|
||
|
||
const openModal = () => {
|
||
setModalOpen(true);
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!formData.year || !formData.orgUnitType || formData.org_unit_id.length === 0 || !formData.formTypeCode) {
|
||
alert('Пожалуйста, заполните все поля');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setLoadingUpdate(true);
|
||
|
||
await TasksApi.create({
|
||
year: Number(formData.year),
|
||
form_type_code: formData.formTypeCode,
|
||
org_unit_ids: formData.org_unit_id,
|
||
});
|
||
|
||
setPage(1);
|
||
loadTasks(1, limit);
|
||
|
||
setModalOpen(false);
|
||
setFormData({
|
||
year: '',
|
||
orgUnitType: '',
|
||
org_unit_id: [],
|
||
formTypeCode: '',
|
||
});
|
||
} catch (error) {
|
||
toast.error(error.response?.data?.detail || error.response?.data?.message || 'Ошибка при создании задачи');
|
||
} finally {
|
||
setLoadingUpdate(false);
|
||
}
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
setModalOpen(false);
|
||
setFormData({
|
||
year: '',
|
||
orgUnitType: '',
|
||
org_unit_id: [],
|
||
formTypeCode: '',
|
||
});
|
||
};
|
||
|
||
const handleFormChange = (field, value) => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
[field]: value,
|
||
}));
|
||
};
|
||
|
||
const handleOrgUnitTypeChange = (value) => {
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
orgUnitType: value,
|
||
org_unit_id: [],
|
||
}));
|
||
};
|
||
|
||
const handleSelectAllOrgUnits = (checked) => {
|
||
handleFormChange('org_unit_id', checked ? filteredOrgUnits.map((unit) => unit.id) : []);
|
||
};
|
||
|
||
const loadTasks = useCallback(
|
||
async (currentPage, currentLimit) => {
|
||
const currentRequest = ++requestId.current;
|
||
setLoading(true);
|
||
try {
|
||
const skip = (currentPage - 1) * currentLimit;
|
||
const params = {
|
||
offset: skip,
|
||
limit: currentLimit,
|
||
...(yearFilter && { year: Number(yearFilter) }),
|
||
...(formTypeFilter && { form_type: formTypeFilter }),
|
||
...(orgUnitIdsFilter.length > 0 && { org_units: orgUnitIdsFilter.join(',') }),
|
||
...(user?.role_id === ROLES_NAME_ID.admin && deleteStatusFilter && { delete_status: deleteStatusFilter }),
|
||
};
|
||
|
||
const res = await TasksApi.list(params);
|
||
const data = res.result;
|
||
if (currentRequest !== requestId.current) return;
|
||
const visit = restoreVisit.current;
|
||
restoreVisit.current = null;
|
||
if (visit && !data.some((item) => String(item.id) === visit.id)) {
|
||
const foundPage = await findItemPage({
|
||
id: visit.id, currentPage, pageSize: currentLimit, count: res.count,
|
||
fetchPage: async (nextPage) => (await TasksApi.list({ ...params, offset: (nextPage - 1) * currentLimit })).result,
|
||
isCancelled: () => currentRequest !== requestId.current,
|
||
});
|
||
if (currentRequest !== requestId.current) return;
|
||
if (foundPage) {
|
||
const savedUrl = new URL(visit.url, window.location.origin);
|
||
savedUrl.searchParams.set('page', String(foundPage));
|
||
rememberListVisit('tasks', visit.id, `${savedUrl.pathname}${savedUrl.search}`);
|
||
setSearchParams(savedUrl.searchParams, { replace: true });
|
||
return;
|
||
}
|
||
}
|
||
|
||
setItems(data);
|
||
setTotalCount(res.count);
|
||
} catch (_error) {
|
||
if (currentRequest === requestId.current) toast.error('Ошибка загрузки задач');
|
||
} finally {
|
||
if (currentRequest === requestId.current) setLoading(false);
|
||
}
|
||
},
|
||
[yearFilter, formTypeFilter, orgUnitIdsFilter, deleteStatusFilter, user?.role_id, setSearchParams],
|
||
);
|
||
|
||
const handlePageChange = (_event, value) => {
|
||
restoreVisit.current = null;
|
||
setPage(value);
|
||
};
|
||
|
||
const handleLimitChange = (event) => {
|
||
const newLimit = event.target.value;
|
||
updateFilter('limit', newLimit);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
if (user.role_id !== ROLES_NAME_ID.admin) {
|
||
setSspList(user.org_units || []);
|
||
return;
|
||
}
|
||
SspApi.getAll({ is_active: true })
|
||
.then((data) => {
|
||
if (data.result) {
|
||
setSspList(data.result);
|
||
}
|
||
})
|
||
.catch(() => toast.error('Ошибка загрузки подразделений'));
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
loadTasks(page, limit);
|
||
return () => {
|
||
requestId.current++;
|
||
};
|
||
}, [page, limit, loadTasks]);
|
||
|
||
const handleFilterChange = (name, value) => {
|
||
updateFilter(name, value);
|
||
};
|
||
|
||
const handleClearFilters = () => {
|
||
restoreVisit.current = null;
|
||
setSearchParams((currentParams) => {
|
||
const nextParams = new URLSearchParams(currentParams);
|
||
nextParams.set('page', '1');
|
||
nextParams.delete('year');
|
||
nextParams.delete('form_type');
|
||
nextParams.delete('org_units');
|
||
nextParams.delete('delete_status');
|
||
return nextParams;
|
||
});
|
||
};
|
||
|
||
if (!user) return;
|
||
|
||
return (
|
||
<>
|
||
<PageContainer>
|
||
<HeaderSwitch />
|
||
<PaginatedList
|
||
skeletonVariant='task'
|
||
skeletonCount={limit}
|
||
items={items}
|
||
loading={loading}
|
||
totalCount={totalCount}
|
||
page={page}
|
||
limit={limit}
|
||
maxHeight='none'
|
||
onPageChange={handlePageChange}
|
||
onLimitChange={handleLimitChange}
|
||
entityName='задач'
|
||
emptyMessage='Задачи не найдены'
|
||
renderItem={(task, exportProps) => (
|
||
<TaskCardComponent
|
||
key={task.id}
|
||
taskForm={task}
|
||
isLastOpened={String(task.id) === lastVisit?.id}
|
||
fluidWidth
|
||
isAdmin={user.role_id === ROLES_NAME_ID.admin}
|
||
currentUserId={user.id}
|
||
onChanged={() => loadTasks(page, limit)}
|
||
{...exportProps}
|
||
/>
|
||
)}
|
||
getItemId={(task) => task.id}
|
||
getItemTitle={(task) =>
|
||
task.title ||
|
||
`${FORM_TYPE_TRANSLATE[task.form_type_code]}_${task.org_unit ? task.org_unit.title : task_org_unit_id}_${task.year}` ||
|
||
'Задача'
|
||
}
|
||
extraActions={
|
||
user.role_id === ROLES_NAME_ID.admin && (
|
||
<PrimaryButton onClick={openModal} startIcon={<WhitePlus />} sx={{ height: '2.5rem' }}>
|
||
Создать задачу
|
||
</PrimaryButton>
|
||
)
|
||
}
|
||
filterActions={
|
||
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center', minHeight: '2.5rem' }}>
|
||
<Select
|
||
size='small'
|
||
displayEmpty
|
||
value={yearFilter}
|
||
onChange={(event) => handleFilterChange('year', event.target.value)}
|
||
sx={{ width: 130 }}>
|
||
<MenuItem value=''>Все годы</MenuItem>
|
||
{yearOptions.map((year) => (
|
||
<MenuItem key={year} value={year}>
|
||
{year}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
|
||
<Select
|
||
size='small'
|
||
displayEmpty
|
||
value={formTypeFilter}
|
||
onChange={(event) => handleFilterChange('form_type', event.target.value)}
|
||
sx={{ width: 210 }}>
|
||
<MenuItem value=''>Все формы</MenuItem>
|
||
{FORM_TYPE_OPTIONS.map((formType) => (
|
||
<MenuItem key={formType} value={formType}>
|
||
{FORM_TYPE_TRANSLATE[formType]}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
|
||
<Autocomplete
|
||
multiple
|
||
size='small'
|
||
options={sspList}
|
||
disableCloseOnSelect
|
||
noOptionsText='Не найдено'
|
||
getOptionLabel={(option) => option.title || ''}
|
||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||
value={selectedOrgUnits}
|
||
onChange={(_event, newValue) => handleFilterChange('org_units', newValue.map((unit) => unit.id).join(','))}
|
||
renderValue={renderOrgUnitValue}
|
||
renderInput={(params) => <TextField {...params} placeholder={selectedOrgUnits.length ? '' : 'Все подразделения'} />}
|
||
sx={{ width: 480, maxWidth: '100%' }}
|
||
/>
|
||
|
||
{user.role_id === ROLES_NAME_ID.admin && (
|
||
<Select
|
||
size='small'
|
||
displayEmpty
|
||
value={deleteStatusFilter}
|
||
onChange={(event) => handleFilterChange('delete_status', event.target.value)}
|
||
sx={{ width: 230 }}>
|
||
<MenuItem value='active'>Непомеченные</MenuItem>
|
||
<MenuItem value='marked'>Помеченные</MenuItem>
|
||
<MenuItem value='all'>Все задачи</MenuItem>
|
||
</Select>
|
||
)}
|
||
|
||
<ResetFiltersButton onClick={handleClearFilters} />
|
||
</Box>
|
||
}
|
||
/>
|
||
</PageContainer>
|
||
<Modal
|
||
open={modalOpen}
|
||
onClose={handleCancel}
|
||
title='Создать задачу'
|
||
actions={
|
||
<>
|
||
<Button variant='grey' onClick={handleCancel} style={{ height: '2.5rem' }} disabled={loadingUpdate}>
|
||
Отменить
|
||
</Button>
|
||
<Button variant='contained' onClick={handleSubmit} style={{ height: '2.5rem' }} loading={loadingUpdate}>
|
||
Создать
|
||
</Button>
|
||
</>
|
||
}>
|
||
<div className='space-y-4'>
|
||
<FormControl fullWidth size='small'>
|
||
<FormLabel required>Форма</FormLabel>
|
||
<Select
|
||
size='small'
|
||
displayEmpty
|
||
fullWidth
|
||
value={formData.formTypeCode}
|
||
onChange={(e) => handleFormChange('formTypeCode', e.target.value)}
|
||
renderValue={(selected) =>
|
||
selected ? FORM_TYPE_TRANSLATE[selected] : <span style={{ color: 'rgba(0,0,0,0.6)' }}>Не выбрана</span>
|
||
}
|
||
MenuProps={modalSelectMenuProps}
|
||
sx={modalSelectSx}>
|
||
{FORM_TYPE_OPTIONS.map((formType) => (
|
||
<MenuItem key={formType} value={formType}>
|
||
{FORM_TYPE_TRANSLATE[formType]}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<FormControl fullWidth>
|
||
<FormLabel required>Год</FormLabel>
|
||
<TextField
|
||
size='small'
|
||
type='number'
|
||
placeholder='Укажите год выполнения задачи'
|
||
value={formData.year}
|
||
onChange={(e) => handleFormChange('year', e.target.value)}
|
||
/>
|
||
</FormControl>
|
||
|
||
<FormControl fullWidth size='small'>
|
||
<FormLabel required>Тип</FormLabel>
|
||
<Select
|
||
size='small'
|
||
displayEmpty
|
||
fullWidth
|
||
value={formData.orgUnitType}
|
||
onChange={(e) => handleOrgUnitTypeChange(e.target.value)}
|
||
renderValue={(selected) => {
|
||
const option = ORG_UNIT_TYPE_OPTIONS.find((item) => item.value === selected);
|
||
return option ? option.label : <span style={{ color: 'rgba(0,0,0,0.6)' }}>Не выбран</span>;
|
||
}}
|
||
MenuProps={modalSelectMenuProps}
|
||
sx={modalSelectSx}>
|
||
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
|
||
<MenuItem key={option.value} value={option.value}>
|
||
{option.label}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
|
||
<FormControl fullWidth size='small'>
|
||
<FormLabel required>ССП/Региональный филиал</FormLabel>
|
||
<Autocomplete
|
||
multiple
|
||
size='small'
|
||
disabled={!formData.orgUnitType}
|
||
options={filteredOrgUnits}
|
||
disableCloseOnSelect
|
||
noOptionsText='Не найдено'
|
||
getOptionLabel={(option) => option.title || ''}
|
||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||
renderValue={renderOrgUnitValue}
|
||
value={filteredOrgUnits.filter((ssp) => formData.org_unit_id.includes(ssp.id))}
|
||
onChange={(_event, newValue) => {
|
||
handleFormChange(
|
||
'org_unit_id',
|
||
newValue.map((ssp) => ssp.id),
|
||
);
|
||
}}
|
||
renderInput={(params) => (
|
||
<TextField
|
||
{...params}
|
||
placeholder={formData.org_unit_id.length === 0 ? 'Не выбрано' : ''}
|
||
sx={{
|
||
'& .MuiOutlinedInput-root': {
|
||
'& fieldset': {
|
||
borderColor: 'rgba(0,0,0,0.1)',
|
||
},
|
||
},
|
||
}}
|
||
/>
|
||
)}
|
||
slots={{
|
||
paper: OrgUnitsAutocompletePaper,
|
||
}}
|
||
slotProps={{
|
||
paper: {
|
||
onSelectAll: handleSelectAllOrgUnits,
|
||
isAllSelected: isAllOrgUnitsSelected,
|
||
isIndeterminate: isOrgUnitsIndeterminate,
|
||
showSelectAll: filteredOrgUnits.length > 0,
|
||
},
|
||
popper: {
|
||
style: { zIndex: 1301 },
|
||
},
|
||
}}
|
||
/>
|
||
</FormControl>
|
||
</div>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|