diff --git a/backend/api/v1/employees/serializers/department.py b/backend/api/v1/employees/serializers/department.py index 0667c78b..f7063faa 100644 --- a/backend/api/v1/employees/serializers/department.py +++ b/backend/api/v1/employees/serializers/department.py @@ -19,6 +19,7 @@ class DepartmentSerializer(serializers.ModelSerializer): allow_null=True, ) employees_count = serializers.IntegerField(read_only=True) + role_only_count = serializers.IntegerField(read_only=True) class Meta: model = Department @@ -29,6 +30,7 @@ class DepartmentSerializer(serializers.ModelSerializer): "head", "head_id", "employees_count", + "role_only_count", ) diff --git a/backend/api/v1/employees/views/departments.py b/backend/api/v1/employees/views/departments.py index 0bf96626..fc0ed460 100644 --- a/backend/api/v1/employees/views/departments.py +++ b/backend/api/v1/employees/views/departments.py @@ -130,18 +130,34 @@ class DepartmentViewSet(viewsets.ModelViewSet): active_links = EmployeeDepartment.objects.filter( department_id=OuterRef("pk"), is_active=True ) + active_role_assignments = RoleAssignment.objects.filter( + role__department_id=OuterRef("pk"), + is_active=True, + ).exclude( + employee__departments_links__department_id=OuterRef("pk"), + employee__departments_links__is_active=True, + ) active_count_subq = ( active_links.values("department_id") .annotate(c=Count("employee_id", distinct=True)) .values("c")[:1] ) + role_only_count_subq = ( + active_role_assignments.values("role__department_id") + .annotate(c=Count("employee_id", distinct=True)) + .values("c")[:1] + ) qs = qs.annotate( active_count=Coalesce( Subquery(active_count_subq, output_field=IntegerField()), Value(0), ), + role_only_count=Coalesce( + Subquery(role_only_count_subq, output_field=IntegerField()), + Value(0), + ), head_in_active=Exists( active_links.filter(employee_id=OuterRef("head_id")) ), diff --git a/backend/tests/api/v1/employees/test_departments.py b/backend/tests/api/v1/employees/test_departments.py index ed4c05d8..787fb5d0 100644 --- a/backend/tests/api/v1/employees/test_departments.py +++ b/backend/tests/api/v1/employees/test_departments.py @@ -57,6 +57,7 @@ from employees.models import ( DepartmentPermission, DepartmentRole, EmployeeDepartment, + RoleAssignment, ) from rest_framework import status from rest_framework.test import APIClient @@ -201,6 +202,57 @@ def test_employees_count_not_double_count_head_if_in_links(api_client: APIClient data = resp.json() assert data["employees_count"] == 3 + +def test_role_only_count_counts_only_active_role_assignees_without_membership( + api_client: APIClient, +): + user = make_user("u-role-only@example.com") + api_client.force_authenticate(user=user) + + dept = Department.objects.create(name="Dept") + role = make_role(dept, "Auditor") + + role_only_1 = make_user("role-only-1@example.com") + role_only_2 = make_user("role-only-2@example.com") + member_with_role = make_user("member-with-role@example.com") + inactive_member_with_role = make_user("inactive-member-with-role@example.com") + + EmployeeDepartment.objects.create( + employee=member_with_role, + department=dept, + is_active=True, + ) + EmployeeDepartment.objects.create( + employee=inactive_member_with_role, + department=dept, + is_active=False, + ) + + RoleAssignment.objects.create(employee=role_only_1, role=role, is_active=True) + RoleAssignment.objects.create(employee=role_only_2, role=role, is_active=True) + RoleAssignment.objects.create( + employee=member_with_role, + role=role, + is_active=True, + ) + RoleAssignment.objects.create( + employee=inactive_member_with_role, + role=role, + is_active=True, + ) + RoleAssignment.objects.create( + employee=make_user("inactive-role-only@example.com"), + role=role, + is_active=False, + ) + + url = reverse("api:v1:departments-detail", args=[dept.pk]) + resp = api_client.get(url) + + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert data["role_only_count"] == 3 + # ---------- tests: create/destroy ---------- def test_create_requires_staff(api_client: APIClient): diff --git a/frontend/src/app/departments/page.tsx b/frontend/src/app/departments/page.tsx index 3b53d1fe..af940b53 100644 --- a/frontend/src/app/departments/page.tsx +++ b/frontend/src/app/departments/page.tsx @@ -78,6 +78,8 @@ export default function DepartmentsPage() { const headName = department.head ? `${department.head.last_name} ${department.head.first_name}`.trim() : "Не назначен"; + const directMembersCount = department.employees_count ?? 0; + const roleOnlyCount = department.role_only_count ?? 0; return ( @@ -87,9 +89,14 @@ export default function DepartmentsPage() {

Руководитель: {headName}

{department.description || "Описание не заполнено"}

-
+
- {department.employees_count ?? 0} + {directMembersCount} + • + {roleOnlyCount}
diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 1a8945b5..41604943 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -143,16 +143,10 @@ export default function EmployeesPage() { }, [hasMore, loading, loadingMore, page]); const filteredEmployees = useMemo(() => { - const sorted = [...employees].sort((left, right) => { - const leftName = `${left.last_name || ""} ${left.first_name || ""}`.trim(); - const rightName = `${right.last_name || ""} ${right.first_name || ""}`.trim(); - return leftName.localeCompare(rightName, "ru"); - }); - const query = search.trim().toLowerCase(); - if (!query) return sorted; + if (!query) return employees; - return sorted.filter((employee) => { + return employees.filter((employee) => { const fullName = `${employee.last_name || ""} ${employee.first_name || ""} ${employee.patronymic || ""}`.trim().toLowerCase(); const position = (employee.position?.name || "").toLowerCase(); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d15131bc..cfb7c610 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -182,6 +182,15 @@ export class ApiClientBase { // eslint-disable-next-line @typescript-eslint/no-explicit-any async updateCurrentUserProfile(data: Record): Promise { + const hasFile = Object.values(data).some((value) => value instanceof File); + + if (!hasFile) { + return this.request('/api/v1/employees/me/', { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + const formData = new FormData(); Object.keys(data).forEach((key) => { if (Array.isArray(data[key])) { diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts index 622d4b02..7e377dbc 100644 --- a/frontend/src/lib/api/employees.ts +++ b/frontend/src/lib/api/employees.ts @@ -13,13 +13,14 @@ export function createEmployeesApi(request: RequestFn) { request('/api/v1/directory/me/login/'), refreshDirectoryLogin: () => request('/api/v1/directory/me/login/refresh/', { method: 'POST' }), - getEmployees: (params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }) => { + getEmployees: (params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean; ordering?: string }) => { const qp = new URLSearchParams(); if (params?.search) qp.append('search', params.search); if (params?.department) qp.append('department', params.department); if (params?.page) qp.append('page', params.page.toString()); if (params?.limit) qp.append('limit', params.limit.toString()); if (params?.is_active !== undefined) qp.append('active', params.is_active.toString()); + if (params?.ordering) qp.append('ordering', params.ordering); const qs = qp.toString(); return request(`/api/v1/employees/${qs ? '?' + qs : ''}`); }, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index d191a4f4..8aeb2883 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -121,6 +121,7 @@ export interface Department { parent?: number; head?: User; employees_count?: number; + role_only_count?: number; } export interface DepartmentMemberRole {