diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index acdc963b..232d9385 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -12,6 +12,7 @@ from attendance.models import AttendanceRecord from django.contrib.contenttypes.models import ContentType from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery from django.utils.crypto import get_random_string +from django.utils import timezone from employees.constants import ( ACTION_DISMISSED, ACTIVATING_MARKER_ACTIONS, @@ -24,6 +25,7 @@ from employees.models import ( RoleAssignment, Skill, ) +from employees.services.personnel_state import calculate_employee_tenure_days from employees.utils import _to_bool from rest_framework import filters, viewsets from rest_framework.decorators import action @@ -97,6 +99,27 @@ class EmployeeViewSet(viewsets.ModelViewSet): ), ) + @staticmethod + def _years_between(start_date, end_date) -> int | None: + if not start_date: + return None + years = end_date.year - start_date.year + if (end_date.month, end_date.day) < (start_date.month, start_date.day): + years -= 1 + return max(years, 0) + + @staticmethod + def _employee_stats_person(employee, *, age_years=None, tenure_days=None): + if not employee: + return None + full_name = employee.get_full_name() + return { + "id": employee.id, + "full_name": full_name or employee.email or f"Сотрудник #{employee.id}", + "age_years": age_years, + "tenure_days": tenure_days, + } + def get_permissions(self): if self.action == "create": return [IsAuthenticated(), AdminOrActionOrModelPerms()] @@ -361,6 +384,98 @@ class EmployeeViewSet(viewsets.ModelViewSet): except Exception: pass + @action(detail=False, methods=["get"], url_path="stats") + def stats(self, request): + """Краткая статистика по активным сотрудникам для списка сотрудников.""" + today = timezone.localdate() + employees = list( + Employee.objects.filter(is_active=True) + .only( + "id", + "email", + "first_name", + "last_name", + "patronymic", + "gender", + "birth_date", + ) + .prefetch_related( + Prefetch( + "actions", + queryset=EmployeeAction.objects.only( + "id", + "employee_id", + "action", + "date", + ).order_by("date", "id"), + to_attr="stats_actions", + ) + ) + ) + + total = len(employees) + male_count = sum(1 for employee in employees if employee.gender == 1) + female_count = sum(1 for employee in employees if employee.gender == 2) + unknown_gender_count = total - male_count - female_count + + ages: list[tuple[Employee, int]] = [] + tenure_values: list[tuple[Employee, int]] = [] + + for employee in employees: + age_years = self._years_between(employee.birth_date, today) + if age_years is not None: + ages.append((employee, age_years)) + + tenure_days = calculate_employee_tenure_days( + employee, + today, + actions=getattr(employee, "stats_actions", []), + ) + if tenure_days is not None: + tenure_values.append((employee, tenure_days)) + + youngest = min(ages, key=lambda item: (item[1], item[0].id), default=None) + most_experienced = max( + tenure_values, + key=lambda item: (item[1], -item[0].id), + default=None, + ) + average_age_years = ( + round(sum(age for _, age in ages) / len(ages), 1) if ages else None + ) + average_tenure_days = ( + round(sum(days for _, days in tenure_values) / len(tenure_values)) + if tenure_values + else None + ) + + return Response( + { + "as_of": today, + "total": total, + "male_count": male_count, + "female_count": female_count, + "unknown_gender_count": unknown_gender_count, + "average_age_years": average_age_years, + "youngest_employee": self._employee_stats_person( + youngest[0], + age_years=youngest[1], + ) + if youngest + else None, + "most_experienced_employee": self._employee_stats_person( + most_experienced[0], + tenure_days=most_experienced[1], + ) + if most_experienced + else None, + "longest_tenure_days": most_experienced[1] + if most_experienced + else None, + "average_tenure_days": average_tenure_days, + } + ) + @action(detail=False, methods=["get", "patch"]) def me(self, request): """GET — профиль текущего пользователя; PATCH — частичное обновление.""" diff --git a/backend/employees/services/personnel_state.py b/backend/employees/services/personnel_state.py index 5480fd4a..e68e62bb 100644 --- a/backend/employees/services/personnel_state.py +++ b/backend/employees/services/personnel_state.py @@ -4,8 +4,11 @@ from dataclasses import dataclass from datetime import date, datetime from typing import Iterable +from django.utils import timezone + from employees.constants import ( ACTION_DISMISSED, + ACTION_REMOTE, ACTION_WORKING, ACTION_LABELS, ACTIVATING_MARKER_ACTIONS, @@ -18,6 +21,10 @@ from employees.constants import ( from employees.models import Employee, EmployeeAction PERSONNEL_STATUS_NORMAL = "normal" +EMPLOYMENT_START_ACTIONS = ACTIVATING_MARKER_ACTIONS | { + ACTION_WORKING, + ACTION_REMOTE, +} @dataclass(frozen=True) @@ -109,6 +116,58 @@ def _sorted_actions(actions: Iterable[EmployeeAction]) -> list[EmployeeAction]: ) +def calculate_employee_tenure_days( + employee: Employee, + target_date: date, + *, + actions: Iterable[EmployeeAction] | None = None, +) -> int | None: + """Считает фактический стаж по кадровым событиям. + + Период работы открывается приемом/восстановлением или рабочим постоянным + статусом и закрывается увольнением. Разрывы между увольнением и повторным + приемом в стаж не входят. + """ + employee_actions = _sorted_actions( + actions + if actions is not None + else employee.actions.select_related("source_request").all() + ) + total_days = 0 + current_start: date | None = None + has_employment_period = False + + for action in employee_actions: + action_date = _action_date(action) + if action_date is None or action_date > target_date: + continue + + if action.action in EMPLOYMENT_START_ACTIONS: + if current_start is None: + current_start = action_date + has_employment_period = True + continue + + if action.action == ACTION_DISMISSED and current_start is not None: + total_days += max((action_date - current_start).days, 0) + current_start = None + + if current_start is not None: + total_days += max((target_date - current_start).days, 0) + + return total_days if has_employment_period else None + + +def _action_date(action: EmployeeAction) -> date | None: + if not action.date: + return None + if isinstance(action.date, datetime): + if timezone.is_aware(action.date): + return timezone.localtime(action.date).date() + return action.date.date() + return action.date + + def _resolve_permanent_action( actions: Iterable[EmployeeAction], ) -> EmployeeAction | None: diff --git a/backend/tests/api/v1/employees/test_employees.py b/backend/tests/api/v1/employees/test_employees.py index 295815de..e386077a 100644 --- a/backend/tests/api/v1/employees/test_employees.py +++ b/backend/tests/api/v1/employees/test_employees.py @@ -18,7 +18,7 @@ from employees.models import ( Position, Skill, ) -from employees.constants import ACTION_DISMISSED # для фильтра actually_active +from employees.constants import ACTION_DISMISSED, ACTION_HIRED, ACTION_REHIRED from tasks.models import ( Task, TaskBoard, @@ -95,6 +95,136 @@ def test_list_ok_for_authenticated(api_client: APIClient): assert e1.id in ids and e2.id in ids +def test_stats_requires_auth(api_client: APIClient): + response = api_client.get(reverse("api:v1:employees-stats")) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_stats_returns_active_employee_demographics(api_client: APIClient, monkeypatch): + monkeypatch.setattr( + "api.v1.employees.views.employees.timezone.localdate", + lambda: dt.date(2026, 7, 12), + ) + viewer = make_user("stats-viewer@example.com") + experienced = make_user( + "stats-experienced@example.com", + first_name="Стаж", + last_name="Большой", + gender=1, + birth_date=dt.date(1980, 1, 1), + ) + youngest = make_user( + "stats-youngest@example.com", + first_name="Молодой", + last_name="Сотрудник", + gender=1, + birth_date=dt.date(2004, 7, 13), + ) + female = make_user( + "stats-female@example.com", + first_name="Анна", + last_name="Статистика", + gender=2, + birth_date=dt.date(1990, 7, 12), + ) + unknown = make_user( + "stats-unknown@example.com", + first_name="Без", + last_name="Пола", + gender=0, + ) + inactive = make_user( + "stats-inactive@example.com", + active=False, + gender=2, + birth_date=dt.date(1970, 1, 1), + ) + + experienced.date_joined = dt.datetime( + 2026, 1, 1, tzinfo=dt.timezone.utc + ) + experienced.save(update_fields=["date_joined"]) + + def action_date(year: int, month: int, day: int) -> dt.datetime: + return dt.datetime(year, month, day, 12, tzinfo=dt.timezone.utc) + + EmployeeAction.objects.bulk_create( + [ + EmployeeAction( + employee=viewer, + action=ACTION_HIRED, + date=action_date(2024, 1, 1), + ), + EmployeeAction( + employee=experienced, + action=ACTION_HIRED, + date=action_date(2020, 1, 1), + ), + EmployeeAction( + employee=experienced, + action=ACTION_DISMISSED, + date=action_date(2022, 1, 1), + ), + EmployeeAction( + employee=experienced, + action=ACTION_REHIRED, + date=action_date(2024, 1, 1), + ), + EmployeeAction( + employee=youngest, + action=ACTION_HIRED, + date=action_date(2025, 1, 1), + ), + EmployeeAction( + employee=female, + action=ACTION_HIRED, + date=action_date(2022, 6, 1), + ), + EmployeeAction( + employee=unknown, + action=ACTION_HIRED, + date=action_date(2024, 6, 1), + ), + EmployeeAction( + employee=inactive, + action=ACTION_HIRED, + date=action_date(2010, 1, 1), + ), + ] + ) + + today = dt.date(2026, 7, 12) + tenure_by_employee = { + viewer: (today - dt.date(2024, 1, 1)).days, + experienced: ( + (dt.date(2022, 1, 1) - dt.date(2020, 1, 1)).days + + (today - dt.date(2024, 1, 1)).days + ), + youngest: (today - dt.date(2025, 1, 1)).days, + female: (today - dt.date(2022, 6, 1)).days, + unknown: (today - dt.date(2024, 6, 1)).days, + } + + api_client.force_authenticate(user=viewer) + response = api_client.get(reverse("api:v1:employees-stats")) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["as_of"] == "2026-07-12" + assert payload["total"] == 5 + assert payload["male_count"] == 2 + assert payload["female_count"] == 1 + assert payload["unknown_gender_count"] == 2 + assert payload["average_age_years"] == 34.3 + assert payload["youngest_employee"]["id"] == youngest.id + assert payload["youngest_employee"]["age_years"] == 21 + assert payload["most_experienced_employee"]["id"] == experienced.id + assert payload["longest_tenure_days"] == tenure_by_employee[experienced] + assert payload["average_tenure_days"] == round( + sum(tenure_by_employee.values()) / len(tenure_by_employee) + ) + + def test_list_includes_visible_linked_tasks_only(api_client: APIClient): user = make_user("employee-link-user@example.com") target = make_user("employee-link-target@example.com") diff --git a/backend/tests/employees/test_personnel_state.py b/backend/tests/employees/test_personnel_state.py index c9b6abbe..cc06e729 100644 --- a/backend/tests/employees/test_personnel_state.py +++ b/backend/tests/employees/test_personnel_state.py @@ -17,6 +17,7 @@ from employees.constants import ( from employees.models import EmployeeAction from employees.services.personnel_state import ( PERSONNEL_STATUS_NORMAL, + calculate_employee_tenure_days, resolve_employee_personnel_state, ) @@ -156,6 +157,37 @@ def test_rehire_restores_previous_permanent_state_after_dismissal(user_factory): assert restored_state.action_id == remote.id +def test_tenure_sums_employee_action_periods(user_factory): + employee = user_factory() + EmployeeAction.objects.create( + employee=employee, + action=ACTION_HIRED, + date=_aware_datetime(2020, 1, 1), + ) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=_aware_datetime(2022, 1, 1), + ) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_REHIRED, + date=_aware_datetime(2024, 1, 1), + ) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_REMOTE, + date=_aware_datetime(2025, 1, 1), + ) + + tenure_days = calculate_employee_tenure_days(employee, date(2026, 7, 12)) + + assert tenure_days == ( + (date(2022, 1, 1) - date(2020, 1, 1)).days + + (date(2026, 7, 12) - date(2024, 1, 1)).days + ) + + def test_overlapping_temporary_intervals_use_priority(user_factory): employee = user_factory() EmployeeAction.objects.create( diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index 3ec5969a..cd04b9f2 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -3,15 +3,24 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; -import { MessageCircle, Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; +import { + BriefcaseBusiness, + CalendarDays, + ChevronRight, + Clock3, + MessageCircle, + Search, + UserRound, + Users, +} from "lucide-react"; import { AppShell } from "../../components/AppShell"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import { getEmployeeActionTone } from "@/lib/users/userDetailUtils"; -import type { Chat, User } from "@/types/api"; +import type { Chat, EmployeeStats, User } from "@/types/api"; type PrivateChat = Chat & { member_ids?: number[] }; @@ -22,6 +31,189 @@ function getInitials(employee: User) { return `${employee.last_name?.[0] || ""}${employee.first_name?.[0] || ""}`; } +function pluralRu(value: number, one: string, few: string, many: string) { + const normalized = Math.abs(Math.trunc(value)); + const mod10 = normalized % 10; + const mod100 = normalized % 100; + if (mod10 === 1 && mod100 !== 11) return one; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few; + return many; +} + +function formatAgeYears(value?: number | null) { + if (value === null || value === undefined) return "—"; + const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1)); + const suffix = Number.isInteger(rounded) + ? pluralRu(rounded, "год", "года", "лет") + : "года"; + return `${rounded.toLocaleString("ru-RU")} ${suffix}`; +} + +function formatDurationDays(days?: number | null) { + if (days === null || days === undefined) return "—"; + if (days < 30) { + return `${days} ${pluralRu(days, "день", "дня", "дней")}`; + } + + const years = Math.floor(days / 365); + const months = Math.floor((days % 365) / 30); + const parts: string[] = []; + if (years) parts.push(`${years} ${pluralRu(years, "год", "года", "лет")}`); + if (months) parts.push(`${months} ${pluralRu(months, "месяц", "месяца", "месяцев")}`); + return parts.join(" ") || "меньше месяца"; +} + +function formatStatsDate(value?: string) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleDateString("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function EmployeeStatsRow({ + icon, + label, + value, + detail, +}: { + icon: ReactNode; + label: string; + value: string; + detail?: string; +}) { + return ( +
+ + {icon} + +
+
+

{label}

+

+ {value} +

+
+ {detail ? ( +

{detail}

+ ) : null} +
+
+ ); +} + +function EmployeeStatsSummary({ + error, + loading, + stats, +}: { + error: string | null; + loading: boolean; + stats: EmployeeStats | null; +}) { + const [open, setOpen] = useState(false); + + if (loading && !stats) { + return ( +
+

Сотрудники

+

Загружаем счетчик...

+
+ ); + } + + if (!stats) { + return error ? ( +
+ {error} +
+ ) : null; + } + + const youngest = stats.youngest_employee; + const experienced = stats.most_experienced_employee; + + return ( +
+
+
+

Сотрудники

+
+

+ {stats.total.toLocaleString("ru-RU")} +

+ + {pluralRu(stats.total, "сотрудник", "сотрудника", "сотрудников")} + +
+
+ + +
+ + {open ? ( +
+ } + label="Мужчины / женщины" + value={`${stats.male_count.toLocaleString("ru-RU")} / ${stats.female_count.toLocaleString("ru-RU")}`} + detail={`Пол не указан: ${stats.unknown_gender_count.toLocaleString("ru-RU")}`} + /> +
+ } + label="Средний возраст" + value={formatAgeYears(stats.average_age_years)} + detail={`Расчет на ${formatStatsDate(stats.as_of)}`} + /> +
+ } + label="Самый молодой" + value={youngest?.full_name || "—"} + detail={youngest ? formatAgeYears(youngest.age_years) : undefined} + /> +
+ } + label="Самый опытный" + value={experienced?.full_name || "—"} + detail={experienced ? formatDurationDays(experienced.tenure_days) : undefined} + /> +
+ } + label="Самый долгий срок работы" + value={formatDurationDays(stats.longest_tenure_days)} + detail={experienced?.full_name} + /> +
+ } + label="Средний срок работы" + value={formatDurationDays(stats.average_tenure_days)} + detail="По кадровым событиям" + /> +
+ ) : null} +
+ ); +} + export default function EmployeesPage() { const router = useRouter(); const { user: currentUser } = useUser(); @@ -33,6 +225,9 @@ export default function EmployeesPage() { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [creatingChatFor, setCreatingChatFor] = useState(null); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(true); + const [statsError, setStatsError] = useState(null); const loaderRef = useRef(null); const isFetchingRef = useRef(false); @@ -70,8 +265,23 @@ export default function EmployeesPage() { } } + async function loadEmployeeStats() { + try { + setStatsLoading(true); + setStatsError(null); + const response = await apiClient.getEmployeeStats(); + setStats(response as EmployeeStats); + } catch (error: unknown) { + console.error("Ошибка загрузки статистики сотрудников:", error); + setStatsError(getErrorMessage(error, "Не удалось загрузить статистику сотрудников")); + } finally { + setStatsLoading(false); + } + } + useEffect(() => { void loadEmployees(1, false); + void loadEmployeeStats(); }, []); async function handleStartChat(employee: User, event: MouseEvent) { @@ -180,6 +390,12 @@ export default function EmployeesPage() { return (
+ +
+ {description} +

+ ); +} + const emptyColumnForm: ColumnFormState = { name: "", color: "#38bdf8", @@ -273,7 +287,7 @@ function TaskCardView({
@@ -386,7 +396,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
-
+
#{task.id} @@ -395,11 +405,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) { {task.title}
- {task.description ? ( -

- {task.description} -

- ) : null} + {task.description ? : null}
{priority.urgencyLabel} срочность diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts index 51a528c6..59672178 100644 --- a/frontend/src/lib/api/employees.ts +++ b/frontend/src/lib/api/employees.ts @@ -13,6 +13,7 @@ export function createEmployeesApi(request: RequestFn) { request('/api/v1/directory/me/login/'), refreshDirectoryLogin: () => request('/api/v1/directory/me/login/refresh/', { method: 'POST' }), + getEmployeeStats: () => request('/api/v1/employees/stats/'), getEmployees: (params?: { search?: string; department?: string; page?: number; page_size?: number; limit?: number; is_active?: boolean; ordering?: string }) => { const qp = new URLSearchParams(); if (params?.search) qp.append('search', params.search); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index e2c2c3cc..5fcfa7b2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -37,6 +37,26 @@ export interface EmployeeAttendanceStatus { display: string; } +export interface EmployeeStatsPerson { + id: number; + full_name: string; + age_years?: number | null; + tenure_days?: number | null; +} + +export interface EmployeeStats { + as_of: string; + total: number; + male_count: number; + female_count: number; + unknown_gender_count: number; + average_age_years: number | null; + youngest_employee: EmployeeStatsPerson | null; + most_experienced_employee: EmployeeStatsPerson | null; + longest_tenure_days: number | null; + average_tenure_days: number | null; +} + export interface EmployeeDepartment { id: number; name: string;