Compare commits
2 Commits
d551939e99
...
658bfe6515
| Author | SHA1 | Date | |
|---|---|---|---|
| 658bfe6515 | |||
|
|
f7dc90e63b |
@ -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)
|
||||||
|
|
||||||
@ -369,4 +371,4 @@ async def add_project(
|
|||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"limit_report_id": limit_report_id,
|
"limit_report_id": limit_report_id,
|
||||||
"current_expenses_report_id": current_expenses_report_id,
|
"current_expenses_report_id": current_expenses_report_id,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user