533 lines
18 KiB
JavaScript
533 lines
18 KiB
JavaScript
import { Box, Typography } from '@mui/material';
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { findItemPage, positiveInteger, readListVisit, rememberListVisit } from '../../utils/listNavigation';
|
||
import { toast } from 'react-toastify';
|
||
import { ProjectsApi } from '../../api/projects';
|
||
import { SspApi } from '../../api/ssp';
|
||
import { useAuth } from '../../app/context/AuthProvider';
|
||
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||
import { DangerOutlinedButton, PrimaryButton } from '../../components/common/Buttons/Buttons';
|
||
import Modal from '../../components/common/Modal/Modal';
|
||
import { WhitePlus } from '../../components/common/icons/icons';
|
||
import { ROLES_NAME_ID } from '../../constants/constants';
|
||
import { DEFAULT_PROJECT_CONFIG } from '../../constants/projectConfig';
|
||
import { ModalCreateProject } from '../ProjectsPage/ModalCreateProject';
|
||
import EditModal from './components/EditModal/EditModal';
|
||
import ProjectSummaryTable from './components/ProjectSummaryTable/ProjectSummaryTable';
|
||
import ProjectsTable from './components/ProjectsTable/ProjectsTable';
|
||
import TableFilters from './components/TableFilters/TableFilters'; // Импортируем компонент фильтров
|
||
import { handleNavigateClick } from './utils/tableHandlers';
|
||
|
||
const NavigationProjectPage = () => {
|
||
const navigate = useNavigate();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const location = useLocation();
|
||
const [lastVisit] = useState(() => readListVisit('projects'));
|
||
const restoreVisit = useRef(lastVisit?.url === `${location.pathname}${location.search}` ? lastVisit : null);
|
||
const branchIdsParam = searchParams.get('branch_ids') || '';
|
||
const branchIds = useMemo(
|
||
() =>
|
||
branchIdsParam
|
||
.split(',')
|
||
.filter(Boolean)
|
||
.map(Number)
|
||
.filter((value) => Number.isInteger(value) && value > 0),
|
||
[branchIdsParam],
|
||
);
|
||
|
||
const [selectedRow, setSelectedRow] = useState(null);
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [selectedRowData, setSelectedRowData] = useState(null);
|
||
const [tableData, setTableData] = useState([]);
|
||
const [summaryData, setSummaryData] = useState([]);
|
||
const [isProjectsLoading, setIsProjectsLoading] = useState(true);
|
||
const [isSummaryLoading, setIsSummaryLoading] = useState(false);
|
||
const [orgUnitNames, setOrgUnitNames] = useState({});
|
||
const [orgUnits, setOrgUnits] = useState([]);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [projectsReloadKey, setProjectsReloadKey] = useState(0);
|
||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||
const [projectToDelete, setProjectToDelete] = useState(null);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
const { user } = useAuth();
|
||
|
||
// Состояния для фильтров
|
||
const [selectedBranches, setSelectedBranches] = useState([]);
|
||
const searchQuery = searchParams.get('search') || '';
|
||
const selectedStatus = searchParams.get('status') || '';
|
||
const [archivedData, setArchivedData] = useState([]);
|
||
const [archivedTotalCount, setArchivedTotalCount] = useState(0);
|
||
const archivedPagination = {
|
||
pageIndex: positiveInteger(searchParams.get('page'), 1) - 1,
|
||
pageSize: positiveInteger(searchParams.get('limit'), 10),
|
||
};
|
||
const setArchivedPagination = (updater) => {
|
||
restoreVisit.current = null;
|
||
const next = typeof updater === 'function' ? updater(archivedPagination) : updater;
|
||
setSearchParams((params) => {
|
||
const result = new URLSearchParams(params);
|
||
result.set('page', String(next.pageIndex + 1));
|
||
result.set('limit', String(next.pageSize));
|
||
return result;
|
||
});
|
||
};
|
||
const updateListFilter = (name, value) => {
|
||
restoreVisit.current = null;
|
||
setSearchParams((params) => {
|
||
const next = new URLSearchParams(params);
|
||
if (value) next.set(name, value); else next.delete(name);
|
||
next.set('page', '1');
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const isArchiveMode = selectedStatus === 'archived';
|
||
|
||
const loadData = useCallback(async () => {
|
||
if (!user) return;
|
||
setIsLoading(true);
|
||
try {
|
||
const isAdmin = user.role_id === ROLES_NAME_ID.admin;
|
||
|
||
if (isAdmin) {
|
||
const sspResponse = await SspApi.getAll({ is_active: true });
|
||
if (!sspResponse.success) {
|
||
toast.error('Ошибка загрузки списка ССП');
|
||
return;
|
||
}
|
||
setOrgUnits(sspResponse.result);
|
||
setOrgUnitNames(Object.fromEntries((sspResponse.result || []).map((orgUnit) => [orgUnit.id, orgUnit.title])));
|
||
} else {
|
||
setOrgUnits(user.org_units ?? []);
|
||
setOrgUnitNames(Object.fromEntries((user.org_units || []).map((orgUnit) => [orgUnit.id, orgUnit.title])));
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading data:', error);
|
||
toast.error(error.response?.data?.message || 'Ошибка загрузки данных');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [user]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
useEffect(() => {
|
||
setSelectedBranches(orgUnits.filter(({ id }) => branchIds.includes(id)));
|
||
}, [branchIds, orgUnits]);
|
||
|
||
const createProjectConfig = useMemo(
|
||
() => ({
|
||
...DEFAULT_PROJECT_CONFIG,
|
||
'ССП/РФ': {
|
||
...DEFAULT_PROJECT_CONFIG['ССП/РФ'],
|
||
options: orgUnits,
|
||
},
|
||
}),
|
||
[orgUnits],
|
||
);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
if (!user || isLoading || isArchiveMode) return undefined;
|
||
|
||
const loadProjects = async () => {
|
||
setIsProjectsLoading(true);
|
||
try {
|
||
const response = await ProjectsApi.listWithReports();
|
||
if (active) setTableData(response.result || []);
|
||
} catch (requestError) {
|
||
console.error('Error loading projects with reports:', requestError);
|
||
if (active) toast.error('Не удалось загрузить проекты');
|
||
} finally {
|
||
if (active) setIsProjectsLoading(false);
|
||
}
|
||
};
|
||
loadProjects();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [user, isLoading, isArchiveMode, projectsReloadKey]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
if (!user || isLoading || !isArchiveMode) return undefined;
|
||
|
||
const loadArchivedProjects = async () => {
|
||
setIsProjectsLoading(true);
|
||
try {
|
||
const offset = archivedPagination.pageIndex * archivedPagination.pageSize;
|
||
const params = {
|
||
status_in: 'archived',
|
||
offset,
|
||
limit: archivedPagination.pageSize,
|
||
...(branchIdsParam ? { branch_ids: branchIdsParam } : {}),
|
||
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
|
||
};
|
||
|
||
const response = await ProjectsApi.listWithReports(params);
|
||
if (!active) return;
|
||
const visit = restoreVisit.current;
|
||
restoreVisit.current = null;
|
||
if (visit && !(response.result || []).some((item) => String(item.id) === visit.id)) {
|
||
const foundPage = await findItemPage({
|
||
id: visit.id, currentPage: archivedPagination.pageIndex + 1,
|
||
pageSize: archivedPagination.pageSize, count: response.count,
|
||
fetchPage: async (page) => (await ProjectsApi.listWithReports({ ...params, offset: (page - 1) * archivedPagination.pageSize })).result || [],
|
||
isCancelled: () => !active,
|
||
});
|
||
if (!active) return;
|
||
if (foundPage) {
|
||
const savedUrl = new URL(visit.url, window.location.origin);
|
||
savedUrl.searchParams.set('page', String(foundPage));
|
||
rememberListVisit('projects', visit.id, `${savedUrl.pathname}${savedUrl.search}`);
|
||
setSearchParams(savedUrl.searchParams, { replace: true });
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (active) {
|
||
setArchivedData(response.result || []);
|
||
setArchivedTotalCount(response.count || 0);
|
||
}
|
||
} catch (requestError) {
|
||
console.error('Error loading archived projects:', requestError);
|
||
if (active) {
|
||
setArchivedData([]);
|
||
setArchivedTotalCount(0);
|
||
toast.error('Не удалось загрузить архивные проекты');
|
||
}
|
||
} finally {
|
||
if (active) setIsProjectsLoading(false);
|
||
}
|
||
};
|
||
|
||
loadArchivedProjects();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [
|
||
user,
|
||
isLoading,
|
||
isArchiveMode,
|
||
branchIdsParam,
|
||
searchQuery,
|
||
archivedPagination.pageIndex,
|
||
archivedPagination.pageSize,
|
||
projectsReloadKey,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
if (!selectedRow?.original?.id) {
|
||
setSummaryData([]);
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}
|
||
|
||
const loadSummary = async () => {
|
||
setIsSummaryLoading(true);
|
||
try {
|
||
const response = await ProjectsApi.getSummary(selectedRow.original.id);
|
||
if (active) setSummaryData(response.result.filter((v) => v.row_type !== 'PROJECT') || []);
|
||
} catch (requestError) {
|
||
console.error('Error loading project summary:', requestError);
|
||
if (active) {
|
||
setSummaryData([]);
|
||
toast.error('Не удалось загрузить сводку проекта');
|
||
}
|
||
} finally {
|
||
if (active) setIsSummaryLoading(false);
|
||
}
|
||
};
|
||
loadSummary();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [selectedRow?.original?.id]);
|
||
|
||
const filteredData = useMemo(() => {
|
||
if (isArchiveMode) {
|
||
return archivedData;
|
||
}
|
||
|
||
if (selectedBranches.length === 0 && !searchQuery && !selectedStatus) {
|
||
return tableData;
|
||
}
|
||
|
||
const query = searchQuery.toLowerCase().trim();
|
||
|
||
return tableData
|
||
.filter((item) => {
|
||
if (selectedStatus && item.status !== selectedStatus) {
|
||
return false;
|
||
}
|
||
// Фильтр по филиалу (только для корневых)
|
||
if (selectedBranches.length > 0) {
|
||
return selectedBranches.some(({ id }) => item.org_unit_id === id);
|
||
}
|
||
return true;
|
||
})
|
||
.map((item) => {
|
||
if (!query) {
|
||
return item;
|
||
}
|
||
|
||
// Фильтруем дочерние элементы - оставляем только те, что совпадают с поиском
|
||
const filteredSubRows = item.sub_rows?.filter((subItem) => (subItem.project || '').toLowerCase().includes(query)) || [];
|
||
|
||
// Возвращаем элемент с отфильтрованными дочерними элементами
|
||
return {
|
||
...item,
|
||
sub_rows: filteredSubRows,
|
||
};
|
||
})
|
||
.filter((item) => {
|
||
if (!query) {
|
||
return true;
|
||
}
|
||
|
||
const parentMatches = (item.name || '').toLowerCase().includes(query);
|
||
const hasMatchingChildren = item.sub_rows && item.sub_rows.length > 0;
|
||
|
||
return parentMatches || hasMatchingChildren;
|
||
});
|
||
}, [archivedData, isArchiveMode, searchQuery, selectedBranches, selectedStatus, tableData]);
|
||
|
||
const onEdit = useCallback((row) => {
|
||
const isSmeta = row.depth > 0;
|
||
const parentProject = isSmeta ? row.getParentRow()?.original : row.original;
|
||
const projectId = parentProject?.id;
|
||
setSelectedRowData({
|
||
...row.original,
|
||
_rowType: isSmeta ? 'smeta' : 'project',
|
||
_projectId: projectId,
|
||
_projectFundingByKoDecision: parentProject?.funding_by_ko_decision,
|
||
});
|
||
setEditModalOpen(true);
|
||
}, []);
|
||
|
||
const onNavigate = useCallback(
|
||
(row) => {
|
||
rememberListVisit('projects', row.original.id || row.original.project, `${location.pathname}${location.search}`);
|
||
handleNavigateClick(row, navigate);
|
||
},
|
||
[navigate, location.pathname, location.search],
|
||
);
|
||
|
||
const handleDeleteProject = async () => {
|
||
if (!projectToDelete?.id) return;
|
||
setIsDeleting(true);
|
||
try {
|
||
await ProjectsApi.delete(projectToDelete.id);
|
||
if (selectedRow?.original?.id === projectToDelete.id) {
|
||
setSelectedRow(null);
|
||
setSummaryData([]);
|
||
}
|
||
setProjectToDelete(null);
|
||
setProjectsReloadKey((key) => key + 1);
|
||
toast.success('Проект успешно удалён');
|
||
} catch (requestError) {
|
||
console.error('Error deleting project:', requestError);
|
||
toast.error(requestError.response?.data?.detail || requestError.response?.data?.message || 'Не удалось удалить проект');
|
||
} finally {
|
||
setIsDeleting(false);
|
||
}
|
||
};
|
||
|
||
const onSaveEdit = async (formData) => {
|
||
setIsSaving(true);
|
||
try {
|
||
if (formData._rowType === 'smeta') {
|
||
const { is_in_plan, is_in_plan_q2, is_in_plan_q3, is_in_plan_q4, development_block, reserve_to_prrs_ahr, reserve_to_prrs_kv } =
|
||
formData;
|
||
await ProjectsApi.updateSmeta(formData._projectId, formData.year, {
|
||
is_in_plan,
|
||
is_in_plan_q2,
|
||
is_in_plan_q3,
|
||
is_in_plan_q4,
|
||
development_block: development_block || null,
|
||
reserve_to_prrs_ahr: reserve_to_prrs_ahr === '' ? null : reserve_to_prrs_ahr,
|
||
reserve_to_prrs_kv: reserve_to_prrs_kv === '' ? null : reserve_to_prrs_kv,
|
||
});
|
||
} else {
|
||
const {
|
||
name,
|
||
status,
|
||
technical_number,
|
||
project_type,
|
||
vsp_format,
|
||
placement_type,
|
||
object_address,
|
||
staff_count,
|
||
total_area,
|
||
org_unit_id,
|
||
krf_decision_date,
|
||
fk_decision_date,
|
||
board_decision_date,
|
||
open_relocate_close_date,
|
||
funding_by_ko_decision,
|
||
} = formData;
|
||
await ProjectsApi.update(formData._projectId, {
|
||
name,
|
||
status: status || null,
|
||
technical_number: technical_number || null,
|
||
project_type: project_type || null,
|
||
vsp_format: vsp_format || null,
|
||
placement_type: placement_type || null,
|
||
object_address: object_address || null,
|
||
staff_count: staff_count === '' ? null : staff_count,
|
||
total_area: total_area === '' ? null : total_area,
|
||
org_unit_id: org_unit_id === '' ? null : org_unit_id,
|
||
krf_decision_date: krf_decision_date || null,
|
||
fk_decision_date: fk_decision_date || null,
|
||
board_decision_date: board_decision_date || null,
|
||
open_relocate_close_date: open_relocate_close_date || null,
|
||
funding_by_ko_decision: funding_by_ko_decision || null,
|
||
});
|
||
}
|
||
setEditModalOpen(false);
|
||
setProjectsReloadKey((key) => key + 1);
|
||
} catch (requestError) {
|
||
console.error('Error updating project summary row:', requestError);
|
||
toast.error(requestError.response?.data?.detail || requestError.response?.data?.message || 'Не удалось сохранить изменения');
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
// Обработчики фильтров
|
||
const handleBranchChange = (value) => {
|
||
setSelectedRow(null);
|
||
setSummaryData([]);
|
||
setSelectedBranches(value);
|
||
updateListFilter('branch_ids', value.map(({ id }) => id).join(','));
|
||
};
|
||
|
||
const handleSearchChange = (value) => {
|
||
updateListFilter('search', value);
|
||
};
|
||
|
||
const handleStatusChange = (value) => {
|
||
setSelectedRow(null);
|
||
setSummaryData([]);
|
||
updateListFilter('status', value);
|
||
};
|
||
|
||
const handleCreateProject = async (formData) => {
|
||
try {
|
||
await ProjectsApi.create({
|
||
name: formData['Название проекта'] || '',
|
||
project_type: formData['Тип проекта развития'] || null,
|
||
vsp_format: formData['Формат ВСП'] || null,
|
||
object_address: formData['Адрес объекта'] || '',
|
||
staff_count: formData['Целевая численность, чел.'] || 0,
|
||
placement_type: formData['Тип размещения'] || null,
|
||
total_area: formData['Площадь размещения, м2'] || 0,
|
||
year: formData.Год || null,
|
||
branch_id: formData['ССП/РФ'] || null,
|
||
});
|
||
setProjectsReloadKey((key) => key + 1);
|
||
toast.success('Проект успешно создан');
|
||
} catch (requestError) {
|
||
console.error('Error creating project:', requestError);
|
||
toast.error(requestError.response?.data?.detail || requestError.response?.data?.message || 'Ошибка при создании проекта');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<HeaderSwitch />
|
||
<Box
|
||
sx={{
|
||
p: '1.5rem',
|
||
maxWidth: '1600px',
|
||
mx: 'auto',
|
||
height: '100vh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||
{/* Компонент фильтров */}
|
||
<TableFilters
|
||
selectedBranches={selectedBranches}
|
||
onBranchChange={handleBranchChange}
|
||
searchQuery={searchQuery}
|
||
onSearchChange={handleSearchChange}
|
||
selectedStatus={selectedStatus}
|
||
onStatusChange={handleStatusChange}
|
||
/>
|
||
<Box sx={{ display: 'flex', gap: '0.75rem' }}>
|
||
<PrimaryButton
|
||
onClick={() => navigate(branchIdsParam ? `/projects/summaries?branch_ids=${branchIdsParam}` : '/projects/summaries')}
|
||
sx={{ height: '2.5rem', whiteSpace: 'nowrap' }}>
|
||
Перейти к сводам
|
||
</PrimaryButton>
|
||
<PrimaryButton onClick={() => setCreateModalOpen(true)} startIcon={<WhitePlus />} sx={{ height: '2.5rem', whiteSpace: 'nowrap' }}>
|
||
Создать проект
|
||
</PrimaryButton>
|
||
</Box>
|
||
</Box>
|
||
|
||
<ProjectsTable
|
||
lastOpenedId={lastVisit?.id}
|
||
data={filteredData}
|
||
isLoading={isLoading || isProjectsLoading}
|
||
isArchiveMode={isArchiveMode}
|
||
archivedTotalCount={archivedTotalCount}
|
||
archivedPagination={archivedPagination}
|
||
onPaginationChange={setArchivedPagination}
|
||
selectedRow={selectedRow}
|
||
onRowSelect={setSelectedRow}
|
||
onDelete={setProjectToDelete}
|
||
onEdit={onEdit}
|
||
onNavigate={onNavigate}
|
||
orgUnitNames={orgUnitNames}
|
||
/>
|
||
|
||
<ProjectSummaryTable data={summaryData} isLoading={isSummaryLoading} selectedRow={selectedRow} />
|
||
|
||
{/* Модалка редактирования */}
|
||
<EditModal
|
||
open={editModalOpen}
|
||
onClose={() => setEditModalOpen(false)}
|
||
rowData={selectedRowData}
|
||
onSave={onSaveEdit}
|
||
isSaving={isSaving}
|
||
orgUnitNames={orgUnitNames}
|
||
/>
|
||
<ModalCreateProject
|
||
open={createModalOpen}
|
||
onClose={() => setCreateModalOpen(false)}
|
||
onCreate={handleCreateProject}
|
||
config={createProjectConfig}
|
||
/>
|
||
<Modal
|
||
open={Boolean(projectToDelete)}
|
||
onClose={() => !isDeleting && setProjectToDelete(null)}
|
||
title='Удалить проект?'
|
||
size='small'
|
||
actions={
|
||
<>
|
||
<PrimaryButton onClick={() => setProjectToDelete(null)} disabled={isDeleting}>
|
||
Отмена
|
||
</PrimaryButton>
|
||
<DangerOutlinedButton onClick={handleDeleteProject} disabled={isDeleting}>
|
||
{isDeleting ? 'Удаление...' : 'Удалить'}
|
||
</DangerOutlinedButton>
|
||
</>
|
||
}>
|
||
<Typography>Проект «{projectToDelete?.name}» будет удалён без возможности восстановления.</Typography>
|
||
</Modal>
|
||
</Box>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default NavigationProjectPage;
|