From f7dc90e63b381039ab059ce297112b950de05a55 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Wed, 26 Aug 2026 13:52:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D0=B2=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/v1/projects.py | 4 ++- api/src/repository/project_repository.py | 31 ++++++++++++++-- api/src/services/project_service.py | 2 ++ .../integration/test_projects_api_smoke.py | 27 ++++++++++++++ web/src/components/common/icons/icons.jsx | 36 +++++++++---------- .../ProjectPages/NavigationProjectPage.jsx | 25 ++----------- 6 files changed, 81 insertions(+), 44 deletions(-) diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index 3b9e3a6..120bd24 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -78,6 +78,7 @@ async def get_projects_with_reports( limit: Optional[int] = None, offset: Optional[int] = None, status_in: Optional[str] = None, + search: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), ) -> BaseListResponse[dict]: @@ -91,6 +92,7 @@ async def get_projects_with_reports( limit=limit, offset=offset, status_in=status_in, + search=search, ) return BaseListResponse(result=projects, count=count) @@ -369,4 +371,4 @@ async def add_project( "project_id": project_id, "limit_report_id": limit_report_id, "current_expenses_report_id": current_expenses_report_id, - } \ No newline at end of file + } diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 18bd82f..d18801d 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -1,6 +1,6 @@ import json -from sqlalchemy import exists, false, func, select, text +from sqlalchemy import String, cast, exists, false, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.org_unit import OrgUnit @@ -45,6 +45,7 @@ class ProjectRepository: branch_id: int | None, org_unit_ids: list[int] | None, status_in: list[str] | None = None, + search: str | None = None, ): if year is not None: query = query.where( @@ -62,6 +63,27 @@ class ProjectRepository: query = query.where(Project.org_unit_id == branch_id) if status_in is not None: query = query.where(Project.status.in_(status_in)) + if search is not None and (normalized_search := search.strip()): + search_pattern = ( + f"%{normalized_search.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')}%" + ) + matching_year_exists = exists( + select(1) + .select_from(RfProjectYear) + .where( + RfProjectYear.project_id == Project.id, + func.concat("Смета_", cast(RfProjectYear.year, String)).ilike( + search_pattern, + escape="\\", + ), + ) + ) + query = query.where( + or_( + Project.name.ilike(search_pattern, escape="\\"), + matching_year_exists, + ) + ) if org_unit_ids is not None: if len(org_unit_ids) == 0: query = query.where(false()) @@ -171,10 +193,11 @@ class ProjectRepository: with_count: bool = False, org_unit_ids: list[int] | None = None, status_in: list[str] | None = None, + search: str | None = None, ) -> list[dict] | tuple[int, list[dict]]: project_query = select(Project).order_by(Project.id) project_query = self._apply_project_filters( - project_query, year, branch_id, org_unit_ids, status_in + project_query, year, branch_id, org_unit_ids, status_in, search ) if offset is not None: project_query = project_query.offset(offset) @@ -193,6 +216,8 @@ class ProjectRepository: .order_by(RfProjectYear.year) ) for rpy, smeta in (await self.db.execute(years_query)).all(): + if search and search.strip().lower() not in f"Смета_{rpy.year}".lower(): + continue years_by_project.setdefault(rpy.project_id, []).append((rpy, smeta)) payload = [ @@ -205,7 +230,7 @@ class ProjectRepository: count_query = select(func.count(Project.id)) count_query = self._apply_project_filters( - count_query, year, branch_id, org_unit_ids, status_in + count_query, year, branch_id, org_unit_ids, status_in, search ) total = int((await self.db.execute(count_query)).scalar() or 0) return total, payload diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index 0c73834..2590e38 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -44,6 +44,7 @@ class ProjectService: limit: int | None = None, with_count: bool = False, status_in: list[str] | None = None, + search: str | None = None, ) -> list[dict] | tuple[int, list[dict]]: org_unit_ids = await self._allowed_org_unit_ids(user) return await self.project_repo.get_with_reports( @@ -54,6 +55,7 @@ class ProjectService: with_count=with_count, org_unit_ids=org_unit_ids, status_in=status_in, + search=search, ) async def get(self, project_id: int, user: AppUser) -> dict | None: diff --git a/api/tests/integration/test_projects_api_smoke.py b/api/tests/integration/test_projects_api_smoke.py index 2b042d4..752bde1 100644 --- a/api/tests/integration/test_projects_api_smoke.py +++ b/api/tests/integration/test_projects_api_smoke.py @@ -327,6 +327,33 @@ def test_projects_with_reports_filters_by_multiple_statuses(client, admin_tokens assert payload["count"] == len(payload["result"]) +def test_projects_with_reports_applies_search_before_pagination(client, admin_tokens, auth_headers): + project_id, project_name = _create_project(client, admin_tokens, auth_headers) + update_response = client.patch( + f"/api/v1/project/{project_id}", + json={"name": project_name, "status": "archived"}, + headers=auth_headers(admin_tokens), + ) + assert update_response.status_code == 200 + + response = client.get( + "/api/v1/projects/with-reports", + params={ + "status_in": "archived", + "search": project_name.lower(), + "offset": 0, + "limit": 1, + }, + headers=auth_headers(admin_tokens), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["count"] == 1 + assert [project["id"] for project in payload["result"]] == [project_id] + assert payload["result"][0]["sub_rows"] == [] + + def test_upd_project_smoke(client, admin_tokens, auth_headers): project_id, name = _create_project(client, admin_tokens, auth_headers) diff --git a/web/src/components/common/icons/icons.jsx b/web/src/components/common/icons/icons.jsx index b19690f..148da38 100644 --- a/web/src/components/common/icons/icons.jsx +++ b/web/src/components/common/icons/icons.jsx @@ -227,37 +227,37 @@ export const Eye = styled.div` background-position: center; `; -export const HiddenExpenseItems = ({ size = 20 }) => ( +export const HiddenExpenseItems = ({ size = 20, color = '#4A5565' }) => ( ); -export const VisibleExpenseItems = ({ size = 20 }) => ( +export const VisibleExpenseItems = ({ size = 20, color = '#258141' }) => ( ); -export const CurrentStageColumns = ({ size = 20 }) => ( +export const CurrentStageColumns = ({ size = 20, color = '#258141', mutedColor = '#99A1AF' }) => ( ); -export const AllStageColumns = ({ size = 20 }) => ( +export const AllStageColumns = ({ size = 20, color = '#258141' }) => ( ); diff --git a/web/src/pages/ProjectPages/NavigationProjectPage.jsx b/web/src/pages/ProjectPages/NavigationProjectPage.jsx index 64c1817..924361b 100644 --- a/web/src/pages/ProjectPages/NavigationProjectPage.jsx +++ b/web/src/pages/ProjectPages/NavigationProjectPage.jsx @@ -127,37 +127,18 @@ const NavigationProjectPage = () => { try { const offset = archivedPagination.pageIndex * archivedPagination.pageSize; const params = { - status: 'archived', + status_in: 'archived', offset, limit: archivedPagination.pageSize, ...(selectedBranch?.id ? { branch_id: selectedBranch.id } : {}), ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), }; - // TODO: после реализации серверных фильтров и пагинации достаточно - // передать params в listWithReports и использовать response.result/count. - // Пока запрос возвращает полный набор, а код ниже имитирует ответ бэка. const response = await ProjectsApi.listWithReports(params); - const query = searchQuery.toLowerCase().trim(); - const filtered = (response.result || []) - .filter((item) => item.status === 'archived') - .filter((item) => !selectedBranch || item.org_unit_id === selectedBranch.id) - .map((item) => { - if (!query) return item; - return { - ...item, - sub_rows: item.sub_rows?.filter((subItem) => - (subItem.project || '').toLowerCase().includes(query), - ) || [], - }; - }) - .filter((item) => !query - || (item.name || '').toLowerCase().includes(query) - || item.sub_rows.length > 0); if (active) { - setArchivedData(filtered.slice(offset, offset + archivedPagination.pageSize)); - setArchivedTotalCount(filtered.length); + setArchivedData(response.result || []); + setArchivedTotalCount(response.count || 0); } } catch (requestError) { console.error('Error loading archived projects:', requestError);