Compare commits
4 Commits
6b564fc34b
...
182baaff4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
182baaff4e | ||
| 03c88b5681 | |||
| 1607927936 | |||
| 0245b0c3b2 |
@ -5,12 +5,16 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.v1.deps import get_current_active_user_with_set_db
|
from src.api.v1.deps import (
|
||||||
|
get_current_active_user_with_set_db,
|
||||||
|
require_executor,
|
||||||
|
)
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.db.session import get_db
|
from src.db.session import get_db
|
||||||
from src.domain.schemas import (
|
from src.domain.schemas import (
|
||||||
AddForm3LineBody,
|
AddForm3LineBody,
|
||||||
AddProjectBody,
|
AddProjectBody,
|
||||||
|
AddProjectYearBody,
|
||||||
BaseListResponse,
|
BaseListResponse,
|
||||||
BaseSingleResponse,
|
BaseSingleResponse,
|
||||||
CellPatch,
|
CellPatch,
|
||||||
@ -326,6 +330,21 @@ async def delete_project(
|
|||||||
return ResponseBase(success=True, message="Проект удалён")
|
return ResponseBase(success=True, message="Проект удалён")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects/{project_id}/year")
|
||||||
|
async def add_project_year(
|
||||||
|
project_id: int,
|
||||||
|
body: AddProjectYearBody,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: AppUser = Depends(require_executor),
|
||||||
|
) -> dict:
|
||||||
|
project_service = ProjectService(db)
|
||||||
|
return await project_service.add_project_year(
|
||||||
|
project_id=project_id,
|
||||||
|
year=body.year,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects")
|
@router.post("/projects")
|
||||||
async def add_project(
|
async def add_project(
|
||||||
body: AddProjectBody,
|
body: AddProjectBody,
|
||||||
|
|||||||
@ -399,6 +399,10 @@ class UpdProjectBody(BaseModel):
|
|||||||
funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None
|
funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AddProjectYearBody(BaseModel):
|
||||||
|
year: int
|
||||||
|
|
||||||
|
|
||||||
class UpdSmetaBody(BaseModel):
|
class UpdSmetaBody(BaseModel):
|
||||||
is_in_plan: Optional[bool] = None
|
is_in_plan: Optional[bool] = None
|
||||||
is_in_plan_q2: Optional[bool] = None
|
is_in_plan_q2: Optional[bool] = None
|
||||||
|
|||||||
@ -109,6 +109,21 @@ class ProjectRepository:
|
|||||||
total = int((await self.db.execute(count_query)).scalar() or 0)
|
total = int((await self.db.execute(count_query)).scalar() or 0)
|
||||||
return total, payload
|
return total, payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_sub_row(rpy: RfProjectYear, smeta: Smeta | None) -> dict:
|
||||||
|
return {
|
||||||
|
"id": rpy.id,
|
||||||
|
"year": rpy.year,
|
||||||
|
"project": f"Смета_{rpy.year}",
|
||||||
|
"is_in_plan": smeta.is_in_plan if smeta else None,
|
||||||
|
"is_in_plan_q2": smeta.is_in_plan_q2 if smeta else None,
|
||||||
|
"is_in_plan_q3": smeta.is_in_plan_q3 if smeta else None,
|
||||||
|
"is_in_plan_q4": smeta.is_in_plan_q4 if smeta else None,
|
||||||
|
"development_block": smeta.development_block if smeta else None,
|
||||||
|
"reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta and smeta.reserve_to_prrs_ahr is not None else None,
|
||||||
|
"reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta and smeta.reserve_to_prrs_kv is not None else None,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict:
|
def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict:
|
||||||
return {
|
return {
|
||||||
@ -132,18 +147,7 @@ class ProjectRepository:
|
|||||||
"funding_by_ko_decision": project.funding_by_ko_decision,
|
"funding_by_ko_decision": project.funding_by_ko_decision,
|
||||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||||
"sub_rows": [
|
"sub_rows": [
|
||||||
{
|
ProjectRepository._serialize_sub_row(rpy, smeta)
|
||||||
"id": rpy.id,
|
|
||||||
"year": rpy.year,
|
|
||||||
"project": f"Смета_{rpy.year}",
|
|
||||||
"is_in_plan": smeta.is_in_plan if smeta else None,
|
|
||||||
"is_in_plan_q2": smeta.is_in_plan_q2 if smeta else None,
|
|
||||||
"is_in_plan_q3": smeta.is_in_plan_q3 if smeta else None,
|
|
||||||
"is_in_plan_q4": smeta.is_in_plan_q4 if smeta else None,
|
|
||||||
"development_block": smeta.development_block if smeta else None,
|
|
||||||
"reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta and smeta.reserve_to_prrs_ahr is not None else None,
|
|
||||||
"reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta and smeta.reserve_to_prrs_kv is not None else None,
|
|
||||||
}
|
|
||||||
for rpy, smeta in years
|
for rpy, smeta in years
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@ -546,6 +550,34 @@ class ProjectRepository:
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
return self._serialize_smeta(smeta)
|
return self._serialize_smeta(smeta)
|
||||||
|
|
||||||
|
async def add_project_year(self, project_id: int, year: int) -> dict | None:
|
||||||
|
query = text(
|
||||||
|
"""
|
||||||
|
SELECT limit_report_id, current_expenses_report_id
|
||||||
|
FROM v3.add_project_year(
|
||||||
|
CAST(:project_id AS INT),
|
||||||
|
CAST(:year AS INT)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await self.db.execute(query, {"project_id": project_id, "year": year})
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
row = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(RfProjectYear, Smeta)
|
||||||
|
.outerjoin(Smeta, Smeta.rf_project_year_id == RfProjectYear.id)
|
||||||
|
.where(
|
||||||
|
RfProjectYear.project_id == project_id,
|
||||||
|
RfProjectYear.year == year,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
rpy, smeta = row
|
||||||
|
return self._serialize_sub_row(rpy, smeta)
|
||||||
|
|
||||||
async def add_project(
|
async def add_project(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@ -62,7 +62,13 @@ class ProjectService:
|
|||||||
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
|
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
|
||||||
return project
|
return project
|
||||||
|
|
||||||
async def get_instance(self, project_id: int, user: AppUser) -> Project | None:
|
async def get_instance(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
user: AppUser | None = None,
|
||||||
|
org_unit_ids: list[int] | None = None,
|
||||||
|
) -> Project | None:
|
||||||
|
if org_unit_ids is None and user is not None:
|
||||||
org_unit_ids = await self._allowed_org_unit_ids(user)
|
org_unit_ids = await self._allowed_org_unit_ids(user)
|
||||||
return await self.project_repo.get_instance(project_id=project_id, org_unit_ids=org_unit_ids)
|
return await self.project_repo.get_instance(project_id=project_id, org_unit_ids=org_unit_ids)
|
||||||
|
|
||||||
@ -215,6 +221,28 @@ class ProjectService:
|
|||||||
await self.project_repo.upd_smeta(rf_project_year_id, data)
|
await self.project_repo.upd_smeta(rf_project_year_id, data)
|
||||||
return await self.project_repo.get_smeta(rf_project_year_id)
|
return await self.project_repo.get_smeta(rf_project_year_id)
|
||||||
|
|
||||||
|
async def add_project_year(
|
||||||
|
self,
|
||||||
|
project_id: int,
|
||||||
|
year: int,
|
||||||
|
user: AppUser,
|
||||||
|
) -> dict:
|
||||||
|
org_unit_ids = await self._allowed_org_unit_ids(user)
|
||||||
|
project = await self.get_instance(
|
||||||
|
project_id=project_id,
|
||||||
|
org_unit_ids=org_unit_ids,
|
||||||
|
)
|
||||||
|
if not project:
|
||||||
|
raise ValidationException("Проект не найден")
|
||||||
|
existing = await self.project_repo.resolve_rf_project_year_id(
|
||||||
|
project_id=project_id,
|
||||||
|
year=year,
|
||||||
|
org_unit_ids=org_unit_ids,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise ValidationException(f"Год {year} у проекта уже заведён")
|
||||||
|
return await self.project_repo.add_project_year(project_id=project_id, year=year)
|
||||||
|
|
||||||
async def add_project(
|
async def add_project(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@ -337,3 +337,75 @@ def test_upd_smeta_not_found(client, admin_tokens, auth_headers):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload.get("message") == "Смета не найдена"
|
assert payload.get("message") == "Смета не найдена"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_project_year_smoke(client, admin_tokens, auth_headers):
|
||||||
|
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/year",
|
||||||
|
json={"year": 2027},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["year"] == 2027
|
||||||
|
assert payload["project"] == "Смета_2027"
|
||||||
|
assert payload["id"] is not None
|
||||||
|
|
||||||
|
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
|
||||||
|
assert project is not None
|
||||||
|
sub_years = {sr["year"] for sr in project["sub_rows"]}
|
||||||
|
assert 2026 in sub_years
|
||||||
|
assert 2027 in sub_years
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_project_year_duplicate(client, admin_tokens, auth_headers):
|
||||||
|
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/year",
|
||||||
|
json={"year": 2026},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert payload.get("message") == "Год 2026 у проекта уже заведён"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_project_year_project_not_found(client, admin_tokens, auth_headers):
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/projects/99999/year",
|
||||||
|
json={"year": 2027},
|
||||||
|
headers=auth_headers(admin_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert payload.get("message") == "Проект не найден"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_project_year_by_executor(client, admin_tokens, isp_tokens, auth_headers):
|
||||||
|
project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=2, year=2026)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/year",
|
||||||
|
json={"year": 2027},
|
||||||
|
headers=auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["year"] == 2027
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_project_year_by_executor_from_other_org(client, admin_tokens, isp_tokens, auth_headers):
|
||||||
|
"""Исполнитель, привязанный к РФ id=2, не может добавить год проекту на ССП id=1 — ACL."""
|
||||||
|
project_id, _ = _create_project(client, admin_tokens, auth_headers, branch_id=1, year=2026)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/projects/{project_id}/year",
|
||||||
|
json={"year": 2027},
|
||||||
|
headers=auth_headers(isp_tokens),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
payload = response.json()
|
||||||
|
assert payload.get("message") == "Проект не найден"
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export const ProjectsApi = {
|
|||||||
return api.patch(`/projects/${projectId}/smeta/${year}`, data).then((r) => r.data);
|
return api.patch(`/projects/${projectId}/smeta/${year}`, data).then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addYear: (projectId, year) => {
|
||||||
|
return api.post(`/projects/${projectId}/year`, { year }).then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
delete: (id) => {
|
delete: (id) => {
|
||||||
return api.delete(`/projects/${id}`).then((r) => r.data);
|
return api.delete(`/projects/${id}`).then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Box } from '@mui/material';
|
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';
|
||||||
|
|
||||||
@ -16,9 +16,19 @@ 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} onNavigate={() => handleNavigate(path)} table={table} />;
|
return <TableCard key={`${table.sheet}_${table?.direction}_${table?.year}`} onNavigate={() => handleNavigate(path)} table={table} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectTablesByYear = isProject
|
||||||
|
? Object.entries(
|
||||||
|
filteredTables.reduce((groups, table) => {
|
||||||
|
const year = table.year ?? 'Без года';
|
||||||
|
groups[year] = [...(groups[year] || []), table];
|
||||||
|
return groups;
|
||||||
|
}, {}),
|
||||||
|
).sort(([firstYear], [secondYear]) => Number(secondYear) - Number(firstYear))
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -38,7 +48,28 @@ const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isPr
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredTables.map((table) => renderTableCard(table, formInfo))}
|
{isProject
|
||||||
|
? projectTablesByYear.map(([year, tables]) => (
|
||||||
|
<Box key={year} sx={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
py: 0.5,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}>
|
||||||
|
<Typography variant='subtitle3' sx={{ fontWeight: 400 }}>
|
||||||
|
{year === 'Без года' ? year : `${year} год`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{tables.map((table) => renderTableCard(table, formInfo))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
: filteredTables.map((table) => renderTableCard(table, formInfo))}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,8 +6,9 @@ import { toast } from 'react-toastify';
|
|||||||
import { ProjectsApi } from '../../api/projects';
|
import { ProjectsApi } from '../../api/projects';
|
||||||
import { SspApi } from '../../api/ssp';
|
import { SspApi } from '../../api/ssp';
|
||||||
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||||||
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
|
import { DangerOutlinedButton, PrimaryButton } from '../../components/common/Buttons/Buttons';
|
||||||
import { WhitePlus } from '../../components/common/icons/icons';
|
import { WhitePlus } from '../../components/common/icons/icons';
|
||||||
|
import Modal from '../../components/common/Modal/Modal';
|
||||||
import { DEFAULT_PROJECT_CONFIG } from '../../constants/projectConfig';
|
import { DEFAULT_PROJECT_CONFIG } from '../../constants/projectConfig';
|
||||||
import { ModalCreateProject } from '../ProjectsPage/ModalCreateProject';
|
import { ModalCreateProject } from '../ProjectsPage/ModalCreateProject';
|
||||||
import { createFirstColumns, secondColumns } from './columns';
|
import { createFirstColumns, secondColumns } from './columns';
|
||||||
@ -30,6 +31,8 @@ const SummaryPage = () => {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [projectsReloadKey, setProjectsReloadKey] = useState(0);
|
const [projectsReloadKey, setProjectsReloadKey] = useState(0);
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [projectToDelete, setProjectToDelete] = useState(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
// Состояния для фильтров
|
// Состояния для фильтров
|
||||||
const [selectedBranch, setSelectedBranch] = useState('');
|
const [selectedBranch, setSelectedBranch] = useState('');
|
||||||
@ -191,6 +194,26 @@ const SummaryPage = () => {
|
|||||||
handleNavigateClick(row, navigate);
|
handleNavigateClick(row, navigate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const onSaveEdit = async (formData) => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@ -236,7 +259,7 @@ const SummaryPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const firstColumns = useMemo(
|
const firstColumns = useMemo(
|
||||||
() => createFirstColumns({ onEdit, onNavigate, orgUnitNames }),
|
() => createFirstColumns({ onDelete: setProjectToDelete, onEdit, onNavigate, orgUnitNames }),
|
||||||
[orgUnitNames],
|
[orgUnitNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -266,7 +289,7 @@ const SummaryPage = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const summaryProjectsTableOptions = {
|
const projectsTableOptions = {
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
enablePagination: false,
|
enablePagination: false,
|
||||||
@ -362,7 +385,7 @@ const SummaryPage = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectTableOptions = {
|
const summaryProjectTableOptions = {
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
enableColumnFilters: false,
|
enableColumnFilters: false,
|
||||||
enablePagination: false,
|
enablePagination: false,
|
||||||
@ -423,20 +446,23 @@ const SummaryPage = () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const summaryProjectsTable = useMaterialReactTable({
|
const projectsTable = useMaterialReactTable({
|
||||||
columns: firstColumns,
|
columns: firstColumns,
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
...summaryProjectsTableOptions,
|
...projectsTableOptions,
|
||||||
initialState: {
|
initialState: {
|
||||||
columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] },
|
columnPinning: { right: ['actions'], left: ['mrt-row-expand', 'project'] },
|
||||||
},
|
},
|
||||||
state: { isLoading: isProjectsLoading },
|
state: { isLoading: isProjectsLoading },
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectTable = useMaterialReactTable({
|
const summaryProjectTable = useMaterialReactTable({
|
||||||
columns: secondColumns,
|
columns: secondColumns,
|
||||||
data: summaryData,
|
data: summaryData,
|
||||||
...projectTableOptions,
|
...summaryProjectTableOptions,
|
||||||
|
initialState: {
|
||||||
|
columnPinning: { left: ['data.header.name'] },
|
||||||
|
},
|
||||||
state: { isLoading: isSummaryLoading },
|
state: { isLoading: isSummaryLoading },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -514,7 +540,7 @@ const SummaryPage = () => {
|
|||||||
borderRadius: '1rem',
|
borderRadius: '1rem',
|
||||||
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
|
||||||
}}>
|
}}>
|
||||||
<MaterialReactTable table={summaryProjectsTable} />
|
<MaterialReactTable table={projectsTable} />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Вторая таблица */}
|
{/* Вторая таблица */}
|
||||||
@ -543,7 +569,7 @@ const SummaryPage = () => {
|
|||||||
<Box sx={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<CircularProgress size={32} />
|
<CircularProgress size={32} />
|
||||||
</Box>
|
</Box>
|
||||||
) : <MaterialReactTable table={projectTable} />}
|
) : <MaterialReactTable table={summaryProjectTable} />}
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
@ -569,6 +595,25 @@ const SummaryPage = () => {
|
|||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateProject}
|
||||||
config={createProjectConfig}
|
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>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { Box, IconButton, Tooltip } from '@mui/material';
|
import { Box, IconButton, Tooltip } from '@mui/material';
|
||||||
import { Back, EditPenSvg } from '../../components/common/icons/icons';
|
import { Back, EditPenSvg, TrashSvg } from '../../components/common/icons/icons';
|
||||||
import StatusBadge from './components/StatusBadge/StatusBadge';
|
import StatusBadge from './components/StatusBadge/StatusBadge';
|
||||||
import { DEVELOMENT_BLOCK } from './constants';
|
import { DEVELOMENT_BLOCK } from './constants';
|
||||||
|
|
||||||
export const createFirstColumns = (handlers) => {
|
export const createFirstColumns = (handlers) => {
|
||||||
const { onEdit, onNavigate, orgUnitNames = {} } = handlers;
|
const { onDelete, onEdit, onNavigate, orgUnitNames = {} } = handlers;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'project',
|
id: 'project',
|
||||||
@ -46,7 +46,7 @@ export const createFirstColumns = (handlers) => {
|
|||||||
{
|
{
|
||||||
accessorKey: 'krf_decision_date',
|
accessorKey: 'krf_decision_date',
|
||||||
header: 'Дата решения КРФ',
|
header: 'Дата решения КРФ',
|
||||||
size: 120,
|
size: 160,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'fk_decision_date',
|
accessorKey: 'fk_decision_date',
|
||||||
@ -140,6 +140,7 @@ export const createFirstColumns = (handlers) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{row.depth == 0 && (
|
{row.depth == 0 && (
|
||||||
|
<>
|
||||||
<Tooltip title='Перейти'>
|
<Tooltip title='Перейти'>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
@ -150,6 +151,18 @@ export const createFirstColumns = (handlers) => {
|
|||||||
<Back style={{ transform: 'scaleX(-1)' }} />
|
<Back style={{ transform: 'scaleX(-1)' }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title='Удалить проект'>
|
||||||
|
<IconButton
|
||||||
|
variant='outlined'
|
||||||
|
color='error'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(row.original);
|
||||||
|
}}>
|
||||||
|
<TrashSvg fill='currentColor' />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Box, CircularProgress, Stack } from '@mui/material';
|
import { Box, CircularProgress, FormControl, FormLabel, Stack, TextField } from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
@ -7,10 +7,11 @@ import { ProjectsApi } from '../../api/projects'; // Добавьте импор
|
|||||||
import { TasksApi } from '../../api/tasks';
|
import { TasksApi } from '../../api/tasks';
|
||||||
import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal';
|
import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal';
|
||||||
import { BackButton } from '../../components/common/Buttons/BackButton';
|
import { BackButton } from '../../components/common/Buttons/BackButton';
|
||||||
import { PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
import { DangerOutlinedButton, PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
||||||
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
|
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
|
||||||
import { IconWithContent } from '../../components/common/IconWithContent';
|
import { IconWithContent } from '../../components/common/IconWithContent';
|
||||||
import SearchComponent from '../../components/common/SearchComponent';
|
import SearchComponent from '../../components/common/SearchComponent';
|
||||||
|
import Modal from '../../components/common/Modal/Modal';
|
||||||
import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
|
import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
|
||||||
import TablesList from '../../components/common/TableList/TableList';
|
import TablesList from '../../components/common/TableList/TableList';
|
||||||
import { TableIcon } from '../../components/common/icons/icons';
|
import { TableIcon } from '../../components/common/icons/icons';
|
||||||
@ -50,6 +51,10 @@ export default function TaskPage() {
|
|||||||
const [tempProjects, setTempProjects] = useState([]);
|
const [tempProjects, setTempProjects] = useState([]);
|
||||||
const [projects, setProjects] = useState([]);
|
const [projects, setProjects] = useState([]);
|
||||||
const [isProjectData, setIsProjectData] = useState(false);
|
const [isProjectData, setIsProjectData] = useState(false);
|
||||||
|
const [addYearModalOpen, setAddYearModalOpen] = useState(false);
|
||||||
|
const [newProjectYear, setNewProjectYear] = useState('');
|
||||||
|
const [isAddingYear, setIsAddingYear] = useState(false);
|
||||||
|
const [dataReloadKey, setDataReloadKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getData = async () => {
|
const getData = async () => {
|
||||||
@ -92,7 +97,7 @@ export default function TaskPage() {
|
|||||||
if (Number.isFinite(id)) {
|
if (Number.isFinite(id)) {
|
||||||
getData();
|
getData();
|
||||||
}
|
}
|
||||||
}, [id, isProject]);
|
}, [id, isProject, dataReloadKey]);
|
||||||
|
|
||||||
// Объединили два useEffect в один
|
// Объединили два useEffect в один
|
||||||
|
|
||||||
@ -112,6 +117,33 @@ export default function TaskPage() {
|
|||||||
exportSingleForm(id, fileName);
|
exportSingleForm(id, fileName);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseAddYearModal = () => {
|
||||||
|
if (isAddingYear) return;
|
||||||
|
setAddYearModalOpen(false);
|
||||||
|
setNewProjectYear('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProjectYear = async () => {
|
||||||
|
const year = Number(newProjectYear);
|
||||||
|
if (!Number.isInteger(year) || year < 1) {
|
||||||
|
toast.error('Введите корректный год');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsAddingYear(true);
|
||||||
|
await ProjectsApi.addYear(id, year);
|
||||||
|
toast.success(`Год ${year} успешно добавлен`);
|
||||||
|
setAddYearModalOpen(false);
|
||||||
|
setNewProjectYear('');
|
||||||
|
setDataReloadKey((key) => key + 1);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error?.response?.data?.detail || error?.response?.data?.message || 'Не удалось добавить год');
|
||||||
|
} finally {
|
||||||
|
setIsAddingYear(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='flex justify-center pt-12'>
|
<div className='flex justify-center pt-12'>
|
||||||
@ -147,6 +179,11 @@ export default function TaskPage() {
|
|||||||
<PrimaryOutlinedButton variant='outlined' onClick={() => setModalStageOpen(true)}>
|
<PrimaryOutlinedButton variant='outlined' onClick={() => setModalStageOpen(true)}>
|
||||||
<span>Этапы</span>
|
<span>Этапы</span>
|
||||||
</PrimaryOutlinedButton>
|
</PrimaryOutlinedButton>
|
||||||
|
{isProject && (
|
||||||
|
<PrimaryButton onClick={() => setAddYearModalOpen(true)}>
|
||||||
|
Добавить год
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingStageModal
|
<SettingStageModal
|
||||||
isOpen={modalStageOpen}
|
isOpen={modalStageOpen}
|
||||||
@ -170,6 +207,40 @@ export default function TaskPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{isProject && (
|
||||||
|
<Modal
|
||||||
|
open={addYearModalOpen}
|
||||||
|
onClose={handleCloseAddYearModal}
|
||||||
|
title='Добавить год'
|
||||||
|
size='small'
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<DangerOutlinedButton onClick={handleCloseAddYearModal} disabled={isAddingYear}>
|
||||||
|
Отмена
|
||||||
|
</DangerOutlinedButton>
|
||||||
|
<PrimaryButton onClick={handleAddProjectYear} disabled={isAddingYear}>
|
||||||
|
{isAddingYear ? 'Добавление...' : 'Добавить'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<FormLabel required>Год</FormLabel>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
size='small'
|
||||||
|
type='number'
|
||||||
|
placeholder='Введите год'
|
||||||
|
value={newProjectYear}
|
||||||
|
disabled={isAddingYear}
|
||||||
|
onChange={(event) => setNewProjectYear(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') handleAddProjectYear();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user