Fix employee sorting and department counters

This commit is contained in:
lizerk 2026-04-15 02:12:32 +03:00
parent 9ec4c2349f
commit 1296ec9021
8 changed files with 93 additions and 11 deletions

View File

@ -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",
)

View File

@ -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"))
),

View File

@ -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):

View File

@ -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 (
<Link key={department.id} href={`/departments/${department.id}`} className="app-surface-muted block rounded-xl p-4 transition hover:bg-[var(--surface-elevated)]">
@ -87,9 +89,14 @@ export default function DepartmentsPage() {
<p className="app-text-muted mt-1 text-xs">Руководитель: {headName}</p>
<p className="app-text-muted mt-2 text-sm">{department.description || "Описание не заполнено"}</p>
</div>
<div className="app-selected app-accent-text inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs">
<div
className="app-selected app-accent-text inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs"
title="Участники отдела • Только роли без членства"
>
<Users size={12} />
{department.employees_count ?? 0}
<span>{directMembersCount}</span>
<span className="opacity-60">•</span>
<span>{roleOnlyCount}</span>
</div>
</div>
</Link>

View File

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

View File

@ -182,6 +182,15 @@ export class ApiClientBase {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async updateCurrentUserProfile(data: Record<string, any>): Promise<any> {
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])) {

View File

@ -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 : ''}`);
},

View File

@ -121,6 +121,7 @@ export interface Department {
parent?: number;
head?: User;
employees_count?: number;
role_only_count?: number;
}
export interface DepartmentMemberRole {