DFiP_Budget_planing/web/src/pages/ProjectPages/NavigationProjectPage.jsx
2026-10-01 15:43:26 +03:00

533 lines
18 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 { 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;