2026-09-28 19:12:03 +03:00

496 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Autocomplete, Box, Button, FormControl, FormLabel, MenuItem, Select, TextField } from '@mui/material';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { toast } from 'react-toastify';
import { SspApi } from '../../api/ssp';
import { TasksApi } from '../../api/tasks';
import { UsersApi } from '../../api/users';
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 { PageContainer } from '../../components/common/PageContainer';
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 { findItemPage, positiveInteger, readListVisit, rememberListVisit } from '../../utils/listNavigation';
import TasksTable from './TasksTable';
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 [usersById, setUsersById] = 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('Ошибка загрузки подразделений'));
UsersApi.getAll()
.then((data) => {
const users = data.result || [];
setUsersById(Object.fromEntries(users.map((item) => [item.id, item])));
})
.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 style={{ flex: 1, minHeight: 0, minWidth: 0, width: '100%' }}>
<HeaderSwitch />
<TasksTable
items={items}
loading={loading}
totalCount={totalCount}
page={page}
limit={limit}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
lastOpenedId={lastVisit?.id}
usersById={usersById}
isAdmin={user.role_id === ROLES_NAME_ID.admin}
currentUserId={user.id}
onChanged={() => loadTasks(page, limit)}
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>
</>
);
}