добавление пагинация для архивных проектов

This commit is contained in:
PotapovaA 2026-08-26 13:52:53 +03:00
parent 1736e787a6
commit f7dc90e63b
6 changed files with 81 additions and 44 deletions

View File

@ -78,6 +78,7 @@ async def get_projects_with_reports(
limit: Optional[int] = None, limit: Optional[int] = None,
offset: Optional[int] = None, offset: Optional[int] = None,
status_in: Optional[str] = None, status_in: Optional[str] = None,
search: Optional[str] = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db), current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[dict]: ) -> BaseListResponse[dict]:
@ -91,6 +92,7 @@ async def get_projects_with_reports(
limit=limit, limit=limit,
offset=offset, offset=offset,
status_in=status_in, status_in=status_in,
search=search,
) )
return BaseListResponse(result=projects, count=count) return BaseListResponse(result=projects, count=count)

View File

@ -1,6 +1,6 @@
import json 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 sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.org_unit import OrgUnit from src.db.models.org_unit import OrgUnit
@ -45,6 +45,7 @@ class ProjectRepository:
branch_id: int | None, branch_id: int | None,
org_unit_ids: list[int] | None, org_unit_ids: list[int] | None,
status_in: list[str] | None = None, status_in: list[str] | None = None,
search: str | None = None,
): ):
if year is not None: if year is not None:
query = query.where( query = query.where(
@ -62,6 +63,27 @@ class ProjectRepository:
query = query.where(Project.org_unit_id == branch_id) query = query.where(Project.org_unit_id == branch_id)
if status_in is not None: if status_in is not None:
query = query.where(Project.status.in_(status_in)) 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 org_unit_ids is not None:
if len(org_unit_ids) == 0: if len(org_unit_ids) == 0:
query = query.where(false()) query = query.where(false())
@ -171,10 +193,11 @@ class ProjectRepository:
with_count: bool = False, with_count: bool = False,
org_unit_ids: list[int] | None = None, org_unit_ids: list[int] | None = None,
status_in: list[str] | None = None, status_in: list[str] | None = None,
search: str | None = None,
) -> list[dict] | tuple[int, list[dict]]: ) -> list[dict] | tuple[int, list[dict]]:
project_query = select(Project).order_by(Project.id) project_query = select(Project).order_by(Project.id)
project_query = self._apply_project_filters( 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: if offset is not None:
project_query = project_query.offset(offset) project_query = project_query.offset(offset)
@ -193,6 +216,8 @@ class ProjectRepository:
.order_by(RfProjectYear.year) .order_by(RfProjectYear.year)
) )
for rpy, smeta in (await self.db.execute(years_query)).all(): 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)) years_by_project.setdefault(rpy.project_id, []).append((rpy, smeta))
payload = [ payload = [
@ -205,7 +230,7 @@ class ProjectRepository:
count_query = select(func.count(Project.id)) count_query = select(func.count(Project.id))
count_query = self._apply_project_filters( 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) total = int((await self.db.execute(count_query)).scalar() or 0)
return total, payload return total, payload

View File

@ -44,6 +44,7 @@ class ProjectService:
limit: int | None = None, limit: int | None = None,
with_count: bool = False, with_count: bool = False,
status_in: list[str] | None = None, status_in: list[str] | None = None,
search: str | None = None,
) -> list[dict] | tuple[int, list[dict]]: ) -> list[dict] | tuple[int, list[dict]]:
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_with_reports( return await self.project_repo.get_with_reports(
@ -54,6 +55,7 @@ class ProjectService:
with_count=with_count, with_count=with_count,
org_unit_ids=org_unit_ids, org_unit_ids=org_unit_ids,
status_in=status_in, status_in=status_in,
search=search,
) )
async def get(self, project_id: int, user: AppUser) -> dict | None: async def get(self, project_id: int, user: AppUser) -> dict | None:

View File

@ -327,6 +327,33 @@ def test_projects_with_reports_filters_by_multiple_statuses(client, admin_tokens
assert payload["count"] == len(payload["result"]) 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): def test_upd_project_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens, auth_headers) project_id, name = _create_project(client, admin_tokens, auth_headers)

View File

@ -227,37 +227,37 @@ export const Eye = styled.div`
background-position: center; background-position: center;
`; `;
export const HiddenExpenseItems = ({ size = 20 }) => ( export const HiddenExpenseItems = ({ size = 20, color = '#4A5565' }) => (
<svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'> <svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'>
<path d='M3.25 5.25h9.5M3.25 9.25h7.5M3.25 13.25h5.5' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' /> <path d='M3.25 5.25h9.5M3.25 9.25h7.5M3.25 13.25h5.5' stroke={color} strokeWidth='1.25' strokeLinecap='round' />
<path d='M11.2 12.15c.83.72 1.77 1.1 2.8 1.1 1.73 0 3.04-1.07 3.75-2-.37-.48-.87-.98-1.5-1.37' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' /> <path d='M11.2 12.15c.83.72 1.77 1.1 2.8 1.1 1.73 0 3.04-1.07 3.75-2-.37-.48-.87-.98-1.5-1.37' stroke={color} strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' />
<path d='M10.25 8.9A5.8 5.8 0 0 1 14 7.25c.68 0 1.31.17 1.88.45M10 5l7 10' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' /> <path d='M10.25 8.9A5.8 5.8 0 0 1 14 7.25c.68 0 1.31.17 1.88.45M10 5l7 10' stroke={color} strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' />
</svg> </svg>
); );
export const VisibleExpenseItems = ({ size = 20 }) => ( export const VisibleExpenseItems = ({ size = 20, color = '#258141' }) => (
<svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'> <svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'>
<path d='M3.25 5.25h9.5M3.25 9.25h7.5M3.25 13.25h5.5' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' /> <path d='M3.25 5.25h9.5M3.25 9.25h7.5M3.25 13.25h5.5' stroke={color} strokeWidth='1.25' strokeLinecap='round' />
<path d='M10.25 11.25c.77-1 2.03-2 3.75-2s2.98 1 3.75 2c-.77 1-2.03 2-3.75 2s-2.98-1-3.75-2Z' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' /> <path d='M10.25 11.25c.77-1 2.03-2 3.75-2s2.98 1 3.75 2c-.77 1-2.03 2-3.75 2s-2.98-1-3.75-2Z' stroke={color} strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' />
<circle cx='14' cy='11.25' r='1' fill='currentColor' /> <circle cx='14' cy='11.25' r='1' fill={color} />
</svg> </svg>
); );
export const CurrentStageColumns = ({ size = 20 }) => ( export const CurrentStageColumns = ({ size = 20, color = '#258141', mutedColor = '#99A1AF' }) => (
<svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'> <svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'>
<rect x='2.75' y='3.25' width='4' height='13.5' rx='1' stroke='currentColor' strokeWidth='1.25' opacity='.45' /> <rect x='2.75' y='3.25' width='4' height='13.5' rx='1' stroke={mutedColor} strokeWidth='1.25' />
<rect x='8' y='3.25' width='4' height='13.5' rx='1' stroke='currentColor' strokeWidth='1.25' /> <rect x='8' y='3.25' width='4' height='13.5' rx='1' stroke={color} strokeWidth='1.25' />
<rect x='13.25' y='3.25' width='4' height='13.5' rx='1' stroke='currentColor' strokeWidth='1.25' opacity='.45' /> <rect x='13.25' y='3.25' width='4' height='13.5' rx='1' stroke={mutedColor} strokeWidth='1.25' />
<path d='m8.9 10 1 1 1.7-2' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' /> <path d='m8.9 10 1 1 1.7-2' stroke={color} strokeWidth='1.25' strokeLinecap='round' strokeLinejoin='round' />
</svg> </svg>
); );
export const AllStageColumns = ({ size = 20 }) => ( export const AllStageColumns = ({ size = 20, color = '#258141' }) => (
<svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'> <svg width={size} height={size} viewBox='0 0 20 20' fill='none' aria-hidden='true'>
<rect x='2.75' y='3.25' width='4' height='13.5' rx='1' stroke='currentColor' strokeWidth='1.25' /> <rect x='2.75' y='3.25' width='4' height='13.5' rx='1' stroke={color} strokeWidth='1.25' />
<rect x='8' y='3.25' width='4' height='13.5' rx='1' stroke='currentColor' strokeWidth='1.25' /> <rect x='8' y='3.25' width='4' height='13.5' rx='1' stroke={color} strokeWidth='1.25' />
<rect x='13.25' y='3.25' width='4' height='13.5' rx='1' stroke='currentColor' strokeWidth='1.25' /> <rect x='13.25' y='3.25' width='4' height='13.5' rx='1' stroke={color} strokeWidth='1.25' />
<path d='M4.25 10h1M9.5 10h1M14.75 10h1' stroke='currentColor' strokeWidth='1.25' strokeLinecap='round' /> <path d='M4.25 10h1M9.5 10h1M14.75 10h1' stroke={color} strokeWidth='1.25' strokeLinecap='round' />
</svg> </svg>
); );

View File

@ -127,37 +127,18 @@ const NavigationProjectPage = () => {
try { try {
const offset = archivedPagination.pageIndex * archivedPagination.pageSize; const offset = archivedPagination.pageIndex * archivedPagination.pageSize;
const params = { const params = {
status: 'archived', status_in: 'archived',
offset, offset,
limit: archivedPagination.pageSize, limit: archivedPagination.pageSize,
...(selectedBranch?.id ? { branch_id: selectedBranch.id } : {}), ...(selectedBranch?.id ? { branch_id: selectedBranch.id } : {}),
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}), ...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
}; };
// TODO: после реализации серверных фильтров и пагинации достаточно
// передать params в listWithReports и использовать response.result/count.
// Пока запрос возвращает полный набор, а код ниже имитирует ответ бэка.
const response = await ProjectsApi.listWithReports(params); 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) { if (active) {
setArchivedData(filtered.slice(offset, offset + archivedPagination.pageSize)); setArchivedData(response.result || []);
setArchivedTotalCount(filtered.length); setArchivedTotalCount(response.count || 0);
} }
} catch (requestError) { } catch (requestError) {
console.error('Error loading archived projects:', requestError); console.error('Error loading archived projects:', requestError);