Compare commits

..

2 Commits

Author SHA1 Message Date
97ac7af85a Merge pull request 'stage project' (#102) from stage-project into test
Reviewed-on: #102
2026-08-18 15:32:36 +03:00
PotapovaA
0a97211a0c stage project 2026-08-18 15:31:50 +03:00
10 changed files with 230 additions and 85 deletions

View File

@ -27,6 +27,24 @@ export const StagesApi = {
getStages(formId) { getStages(formId) {
return api.get(`/stages/form/${formId}`).then((r) => r.data); 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) { createStageAccessHeader(tableId, stageId, payload) {
return api return api
.post( .post(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { Box, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import TableCard from '../TableCard/TableCard'; 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 navigate = useNavigate();
const handleNavigate = (path) => { 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}`; 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 const projectTablesByYear = isProject

View File

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