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);