stage project

This commit is contained in:
PotapovaA 2026-08-18 15:31:50 +03:00
parent 1f2c8e5411
commit 0a97211a0c
10 changed files with 230 additions and 85 deletions

View File

@ -27,6 +27,24 @@ export const StagesApi = {
getStages(formId) {
return api.get(`/stages/form/${formId}`).then((r) => r.data);
},
getProjectStages(projectId, year, reportType) {
return api
.get(`/stages/project/${projectId}`, { params: { year, report_type: reportType } })
.then((r) => r.data);
},
createProjectStage(projectId, payload) {
return api.post(`/stages/project/${projectId}`, payload).then((r) => r.data);
},
updateProjectStage(projectId, year, reportType, phaseCode, payload) {
return api
.patch(`/stages/project/${projectId}/${year}/${reportType}/${encodeURIComponent(phaseCode)}`, payload)
.then((r) => r.data);
},
deleteProjectStage(projectId, year, reportType, phaseCode) {
return api
.delete(`/stages/project/${projectId}/${year}/${reportType}/${encodeURIComponent(phaseCode)}`)
.then((r) => r.data);
},
createStageAccessHeader(tableId, stageId, payload) {
return api
.post(

View File

@ -11,6 +11,8 @@ import { StageStatusChip } from './StageStatusChip';
import { getStageStatus, getStatusColors } from './StagesTable/utils';
import { stageColumnsHiddenFromPicker } from './constants';
const stageSelectMenuProps = { sx: { zIndex: 70 } };
export const AddStagesModal = ({
isOpen,
onClose,
@ -184,7 +186,12 @@ export const AddStagesModal = ({
<Stack sx={{ marginBottom: '0.75rem', width: '100%' }}>
<FormControl fullWidth>
<FormLabel>Выбор листа</FormLabel>
<Select value={selectedSheet} onChange={handleSheetChange} displayEmpty size='small'>
<Select
value={selectedSheet}
onChange={handleSheetChange}
displayEmpty
size='small'
MenuProps={stageSelectMenuProps}>
<MenuItem value='' disabled>
Выберите лист
</MenuItem>
@ -207,6 +214,7 @@ export const AddStagesModal = ({
onChange={(e) => setSelectedDirection(e.target.value)}
displayEmpty
size='small'
MenuProps={stageSelectMenuProps}
>
<MenuItem value='' disabled>
Выберите направление
@ -242,7 +250,12 @@ export const AddStagesModal = ({
<Stack sx={{ marginBottom: '0.75rem', width: '100%' }}>
<FormControl fullWidth>
<FormLabel>Роль</FormLabel>
<Select value={selectedRole ?? ''} onChange={(e) => setSelectedRole(e.target.value)} displayEmpty size='small'>
<Select
value={selectedRole ?? ''}
onChange={(e) => setSelectedRole(e.target.value)}
displayEmpty
size='small'
MenuProps={stageSelectMenuProps}>
{Object.entries(STAGE_ROLE_RUSSIAN_NAME).map(([key, label]) => (
<MenuItem key={key} value={key}>
{label}
@ -292,4 +305,4 @@ export const AddStagesModal = ({
</ModalContainer>
</ModalBackdrop>
);
};
};

View File

@ -1,10 +1,11 @@
import CloseIcon from '@mui/icons-material/Close';
import { Box, CircularProgress, IconButton, Stack, Typography } from '@mui/material';
import { Box, CircularProgress, Stack } from '@mui/material';
import { useEffect, useState } from 'react';
import { useTaskStages } from '../../hooks/useTaskStages';
import { useProjectStages } from '../../hooks/useProjectStages';
import { SHEET_NAME } from '../../constants/constants';
import { PrimaryButton } from '../common/Buttons/Buttons';
import { ModalSetting } from '../common/Modal/ModalSetting';
import { EditContainer, ModalContainer, ModalContent } from '../common/Modal/ModalStyled';
import Modal from '../common/Modal/Modal';
import { EditContainer } from '../common/Modal/ModalStyled';
import { WhitePlus } from '../common/icons/icons';
import { AddStagesModal } from './AddStagesModal';
import { DeleteStageModal } from './DeleteStageModal';
@ -15,16 +16,25 @@ export const SettingModal = ({
onClose,
formId,
taskId,
projectId,
year,
reportType,
mode = 'form', // 'form' | 'task'
formTypeCode = '',
sheets,
}) => {
const taskStages = useTaskStages(taskId);
const projectStages = useProjectStages(projectId, year, reportType);
const stages = mode === 'project' ? projectStages : taskStages;
const { deleteStage, stagesInfo, getStages, createStage, updateStage, isSaving } = taskStages;
const { deleteStage, stagesInfo, getStages, createStage, updateStage, isSaving } = stages;
const isTaskMode = mode === 'task';
const entityId = taskId;
const entityId = mode === 'project' ? projectId : taskId;
const projectSheetTitle = SHEET_NAME[reportType] || reportType;
const modalTitle = mode === 'project' && reportType
? `Этапы — ${projectSheetTitle}${year ? `, ${year} год` : ''}`
: 'Этапы';
useEffect(() => {
if (isOpen && entityId) {
@ -125,71 +135,57 @@ export const SettingModal = ({
return (
<>
<ModalSetting
isOpen={isOpen}
onClose={onClose}
children={
<ModalContainer>
<ModalContent>
<Stack direction='row' sx={{ width: '100%', justifyContent: 'flex-end' }}>
<IconButton size='small' onClick={onClose}>
<CloseIcon />
</IconButton>
</Stack>
<Modal open={isOpen} onClose={onClose} title={modalTitle} customSize={50}>
<EditContainer style={{ margin: 0 }}>
<Stack direction='row' sx={{ width: '100%', justifyContent: 'flex-end' }}>
<PrimaryButton onClick={handleAddStage} startIcon={<WhitePlus />}>
Добавить этап
</PrimaryButton>
</Stack>
<EditContainer>
<Stack direction='row' sx={{ width: '100%', justifyContent: 'space-between' }}>
<Typography sx={{ fontWeight: 'bold' }}>Этапы</Typography>
<PrimaryButton onClick={handleAddStage} startIcon={<WhitePlus />}>
Добавить этап
</PrimaryButton>
</Stack>
{stagesInfo.isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 4,
}}>
<CircularProgress />
</Box>
) : (
<StagesTable
stages={stagesInfo.stages || []}
onEdit={handleEditStage}
onDelete={!isTaskMode ? handleDeleteStage : undefined}
/>
)}
<AddStagesModal
isOpen={isAddEditModalOpen}
onClose={() => {
setIsAddEditModalOpen(false);
}}
stage={stageForEdit}
createStage={createStage}
updateStage={updateStage}
isTaskMode={isTaskMode}
isSaving={isSaving}
sheetOptions={sheetOptions}
formType={formTypeCode}
/>
{!isTaskMode && (
<DeleteStageModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setStageToDelete(null);
}}
onConfirm={handleConfirmDelete}
stage={stageToDelete}
/>
)}
</EditContainer>
</ModalContent>
</ModalContainer>
}></ModalSetting>
{stagesInfo.isLoading ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 4,
}}>
<CircularProgress />
</Box>
) : (
<StagesTable
stages={stagesInfo.stages || []}
onEdit={handleEditStage}
onDelete={!isTaskMode ? handleDeleteStage : undefined}
/>
)}
<AddStagesModal
isOpen={isAddEditModalOpen}
onClose={() => {
setIsAddEditModalOpen(false);
}}
stage={stageForEdit}
createStage={createStage}
updateStage={updateStage}
isTaskMode={isTaskMode}
isSaving={isSaving}
sheetOptions={sheetOptions}
formType={formTypeCode}
defaultSheet={mode === 'project' ? reportType : ''}
/>
{!isTaskMode && (
<DeleteStageModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setStageToDelete(null);
}}
onConfirm={handleConfirmDelete}
stage={stageToDelete}
/>
)}
</EditContainer>
</Modal>
</>
);
};

View File

@ -103,6 +103,7 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => {
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleCloseMenu}
sx={{ zIndex: 60 }}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',

View File

@ -198,6 +198,7 @@ const CollapsibleTree = ({
open={open}
anchorEl={anchorEl}
onClose={handleClose}
sx={forStage ? { zIndex: 70 } : undefined}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',

View File

@ -1,7 +1,7 @@
import { DIRECTION_TRANSLATE } from '../../../constants/constants';
import { Right, Section, SectionStartPart, SectionTitle } from './TableCard.styled';
const TableCard = ({ table, onNavigate }) => {
const TableCard = ({ table, onNavigate, action }) => {
const handleClick = () => {
if (onNavigate) {
onNavigate(table.id);
@ -23,7 +23,7 @@ const TableCard = ({ table, onNavigate }) => {
</UsersList> */}
</SectionTitle>
</SectionStartPart>
<Right />
<div onClick={(event) => event.stopPropagation()}>{action || <Right />}</div>
</Section>
</div>
);

View File

@ -2,7 +2,7 @@ import { Box, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import TableCard from '../TableCard/TableCard';
const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isProject = false }) => {
const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isProject = false, renderTableAction }) => {
const navigate = useNavigate();
const handleNavigate = (path) => {
@ -16,7 +16,14 @@ const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isPr
}
const path = `${basePath}/form/${formInfo.id}/form-type/${isProject ? 'PROJECT' : formInfo.form_type_code}/${table.sheet}/${table?.direction}/${table?.year}`;
return <TableCard key={`${table.sheet}_${table?.direction}_${table?.year}`} onNavigate={() => handleNavigate(path)} table={table} />;
return (
<TableCard
key={`${table.sheet}_${table?.direction}_${table?.year}`}
onNavigate={() => handleNavigate(path)}
table={table}
action={renderTableAction?.(table)}
/>
);
};
const projectTablesByYear = isProject

View File

@ -60,4 +60,6 @@ export const SHEET_NAME = {
OTCH9F: 'Отчет 9ф',
SMETA: 'Смета',
STRUCTURE: 'Структура',
CURRENT_EXPENSES: 'Текущие расходы',
LIMIT: 'Лимиты',
};

View File

@ -0,0 +1,94 @@
import { useCallback, useState } from 'react';
import { toast } from 'react-toastify';
import { StagesApi } from '../api/stages';
export const useProjectStages = (projectId, year, reportType) => {
const [stagesInfo, setStagesInfo] = useState({ isLoading: false, stages: [] });
const [isSaving, setIsSaving] = useState(false);
const normalizeStage = useCallback(
(stage) => ({ ...stage, sheet: stage.report_type || reportType, year: stage.year || year }),
[reportType, year],
);
const getStages = useCallback(() => {
if (!projectId || !year || !reportType) return;
setStagesInfo((prev) => ({ ...prev, isLoading: true }));
StagesApi.getProjectStages(projectId, year, reportType)
.then((data) => {
setStagesInfo({ stages: (data.result ?? []).map(normalizeStage), isLoading: false });
})
.catch((error) => {
setStagesInfo((prev) => ({ ...prev, isLoading: false }));
toast.error(error?.response?.data?.detail || 'Ошибка при получении этапов');
});
}, [normalizeStage, projectId, reportType, year]);
const createStage = useCallback(
(payload) => {
if (!projectId || !year || !reportType) return Promise.reject('Не задан контекст отчёта проекта');
const { sheet, direction, ...stageData } = payload;
setIsSaving(true);
return StagesApi.createProjectStage(projectId, { ...stageData, year, report_type: reportType })
.then((data) => {
setStagesInfo((prev) => ({ ...prev, stages: [...prev.stages, normalizeStage(data.result)] }));
toast.success('Этап добавлен');
return data;
})
.catch((error) => {
toast.error(error?.response?.data?.detail || 'Ошибка добавления этапа');
throw error;
})
.finally(() => setIsSaving(false));
},
[normalizeStage, projectId, reportType, year],
);
const updateStage = useCallback(
(stage) => {
if (!projectId || !year || !reportType) return Promise.reject('Не задан контекст отчёта проекта');
const { phase_code, role, column_keys, opens_at, closes_at } = stage;
setIsSaving(true);
return StagesApi.updateProjectStage(projectId, year, reportType, phase_code, {
role,
column_keys,
opens_at,
closes_at,
})
.then((data) => {
const updatedStage = normalizeStage(data.result);
setStagesInfo((prev) => ({
...prev,
stages: prev.stages.map((item) => (item.phase_code === phase_code ? updatedStage : item)),
}));
toast.success('Данные об этапе обновлены');
return data;
})
.catch((error) => {
toast.error(error?.response?.data?.detail || 'Ошибка при обновлении этапа');
throw error;
})
.finally(() => setIsSaving(false));
},
[normalizeStage, projectId, reportType, year],
);
const deleteStage = useCallback(
(stage) => {
if (!projectId || !year || !reportType || !stage?.phase_code) return;
StagesApi.deleteProjectStage(projectId, year, reportType, stage.phase_code)
.then(() => {
setStagesInfo((prev) => ({
...prev,
stages: prev.stages.filter((item) => item.phase_code !== stage.phase_code),
}));
toast.success('Этап успешно удален');
})
.catch((error) => toast.error(error?.response?.data?.detail || 'Ошибка при удалении этапа'));
},
[projectId, reportType, year],
);
return { stagesInfo, getStages, createStage, updateStage, deleteStage, isSaving };
};

View File

@ -46,7 +46,7 @@ export default function TaskPage() {
const [tables, setTables] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [query, setQuery] = useState('');
const [modalStageOpen, setModalStageOpen] = useState(false);
const [stageReport, setStageReport] = useState(null);
const [modalProjectOpen, setModalProjectOpen] = useState(false);
const [tempProjects, setTempProjects] = useState([]);
const [projects, setProjects] = useState([]);
@ -176,9 +176,11 @@ export default function TaskPage() {
<SearchComponent placeholder='Поиск по названию или направлению' value={query} onChange={setQuery} />
<Stack sx={{ flexDirection: 'row', spacing: '.5rem', justifyContent: 'end', gap: '.5rem' }}>
<ExportWithTextButton onClick={handleExport} />
<PrimaryOutlinedButton variant='outlined' onClick={() => setModalStageOpen(true)}>
<span>Этапы</span>
</PrimaryOutlinedButton>
{!isProject && (
<PrimaryOutlinedButton variant='outlined' onClick={() => setStageReport({})}>
<span>Этапы</span>
</PrimaryOutlinedButton>
)}
{isProject && (
<PrimaryButton onClick={() => setAddYearModalOpen(true)}>
Добавить год
@ -186,13 +188,15 @@ export default function TaskPage() {
)}
<SettingStageModal
isOpen={modalStageOpen}
onClose={() => setModalStageOpen(false)}
isOpen={stageReport !== null}
onClose={() => setStageReport(null)}
taskId={isProject ? undefined : taskId}
projectId={isProject ? projectId : undefined}
year={stageReport?.year}
reportType={stageReport?.sheet}
mode={isProject ? 'project' : 'task'}
formTypeCode={task?.form_type_code || ''}
sheets={tables}
formTypeCode={isProject ? 'PROJECT' : task?.form_type_code || ''}
sheets={isProject && stageReport ? [stageReport] : tables}
/>
</Stack>
</Box>
@ -204,6 +208,15 @@ export default function TaskPage() {
basePath={'/table'}
formInfo={task}
isProject={isProject}
renderTableAction={
isProject
? (table) => (
<PrimaryOutlinedButton variant='outlined' onClick={() => setStageReport(table)}>
Этапы
</PrimaryOutlinedButton>
)
: undefined
}
/>
</Box>
</Box>