Improve employee stats and task card text
This commit is contained in:
parent
2dfc628e86
commit
01490aa20b
@ -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 — частичное обновление."""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 (
|
||||
<div className="flex items-start gap-3 px-2 py-2.5">
|
||||
<span className="app-badge mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<p className="app-card-caption min-w-0">{label}</p>
|
||||
<p className="max-w-[52%] truncate text-right text-sm font-semibold text-[var(--foreground)]">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
{detail ? (
|
||||
<p className="app-text-wrap app-text-muted mt-1 text-xs">{detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmployeeStatsSummary({
|
||||
error,
|
||||
loading,
|
||||
stats,
|
||||
}: {
|
||||
error: string | null;
|
||||
loading: boolean;
|
||||
stats: EmployeeStats | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="app-surface-muted mb-4 rounded-xl border border-[var(--border-subtle)] px-3 py-2.5">
|
||||
<p className="app-card-caption">Сотрудники</p>
|
||||
<p className="app-text-muted mt-1 text-sm">Загружаем счетчик...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return error ? (
|
||||
<div className="mb-4 app-feedback-warning rounded-xl px-4 py-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const youngest = stats.youngest_employee;
|
||||
const experienced = stats.most_experienced_employee;
|
||||
|
||||
return (
|
||||
<div className="relative mb-4">
|
||||
<div className="app-surface-muted flex items-center justify-between gap-3 rounded-xl border border-[var(--border-subtle)] px-3 py-2.5">
|
||||
<div>
|
||||
<p className="app-card-caption">Сотрудники</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-baseline gap-x-2">
|
||||
<h1 className="text-2xl font-semibold leading-none text-[var(--foreground)]">
|
||||
{stats.total.toLocaleString("ru-RU")}
|
||||
</h1>
|
||||
<span className="app-text-muted text-xs">
|
||||
{pluralRu(stats.total, "сотрудник", "сотрудника", "сотрудников")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="app-action-secondary inline-flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium"
|
||||
aria-expanded={open}
|
||||
>
|
||||
Статистика
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="app-menu absolute right-0 top-full z-30 mt-2 w-full rounded-xl p-2 shadow-[var(--shadow-elevated)] sm:w-[26rem]">
|
||||
<EmployeeStatsRow
|
||||
icon={<UserRound size={16} />}
|
||||
label="Мужчины / женщины"
|
||||
value={`${stats.male_count.toLocaleString("ru-RU")} / ${stats.female_count.toLocaleString("ru-RU")}`}
|
||||
detail={`Пол не указан: ${stats.unknown_gender_count.toLocaleString("ru-RU")}`}
|
||||
/>
|
||||
<div className="border-t border-[var(--border-subtle)]" />
|
||||
<EmployeeStatsRow
|
||||
icon={<CalendarDays size={16} />}
|
||||
label="Средний возраст"
|
||||
value={formatAgeYears(stats.average_age_years)}
|
||||
detail={`Расчет на ${formatStatsDate(stats.as_of)}`}
|
||||
/>
|
||||
<div className="border-t border-[var(--border-subtle)]" />
|
||||
<EmployeeStatsRow
|
||||
icon={<UserRound size={16} />}
|
||||
label="Самый молодой"
|
||||
value={youngest?.full_name || "—"}
|
||||
detail={youngest ? formatAgeYears(youngest.age_years) : undefined}
|
||||
/>
|
||||
<div className="border-t border-[var(--border-subtle)]" />
|
||||
<EmployeeStatsRow
|
||||
icon={<BriefcaseBusiness size={16} />}
|
||||
label="Самый опытный"
|
||||
value={experienced?.full_name || "—"}
|
||||
detail={experienced ? formatDurationDays(experienced.tenure_days) : undefined}
|
||||
/>
|
||||
<div className="border-t border-[var(--border-subtle)]" />
|
||||
<EmployeeStatsRow
|
||||
icon={<Clock3 size={16} />}
|
||||
label="Самый долгий срок работы"
|
||||
value={formatDurationDays(stats.longest_tenure_days)}
|
||||
detail={experienced?.full_name}
|
||||
/>
|
||||
<div className="border-t border-[var(--border-subtle)]" />
|
||||
<EmployeeStatsRow
|
||||
icon={<Users size={16} />}
|
||||
label="Средний срок работы"
|
||||
value={formatDurationDays(stats.average_tenure_days)}
|
||||
detail="По кадровым событиям"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<number | null>(null);
|
||||
const [stats, setStats] = useState<EmployeeStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const [statsError, setStatsError] = useState<string | null>(null);
|
||||
const loaderRef = useRef<HTMLDivElement | null>(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<HTMLButtonElement>) {
|
||||
@ -180,6 +390,12 @@ export default function EmployeesPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="app-surface rounded-2xl p-4">
|
||||
<EmployeeStatsSummary
|
||||
error={statsError}
|
||||
loading={statsLoading}
|
||||
stats={stats}
|
||||
/>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search size={16} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
|
||||
@ -128,6 +128,20 @@ const emptyBoardForm: BoardFormState = {
|
||||
department_ids: [],
|
||||
};
|
||||
|
||||
const taskDescriptionPreviewClass =
|
||||
"app-text-muted mt-1 block max-h-8 w-full max-w-[13rem] overflow-hidden whitespace-normal break-all text-xs leading-4";
|
||||
|
||||
function TaskDescriptionPreview({ description }: { description: string }) {
|
||||
return (
|
||||
<p
|
||||
className={taskDescriptionPreviewClass}
|
||||
style={{ overflowWrap: "anywhere", wordBreak: "break-all" }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyColumnForm: ColumnFormState = {
|
||||
name: "",
|
||||
color: "#38bdf8",
|
||||
@ -273,7 +287,7 @@ function TaskCardView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(task)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-normal text-left"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
@ -283,11 +297,7 @@ function TaskCardView({
|
||||
{task.title}
|
||||
</h3>
|
||||
</div>
|
||||
{task.description ? (
|
||||
<p className="app-text-muted mt-1 line-clamp-2 text-xs">
|
||||
{task.description}
|
||||
</p>
|
||||
) : null}
|
||||
{task.description ? <TaskDescriptionPreview description={task.description} /> : null}
|
||||
</button>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<div ref={menuOpen ? menuRef : null} className="relative">
|
||||
@ -386,7 +396,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
|
||||
<div className="app-action-ghost mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md">
|
||||
<GripVertical size={14} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="min-w-0 flex-1 overflow-hidden whitespace-normal text-left">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="app-badge shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold">
|
||||
#{task.id}
|
||||
@ -395,11 +405,7 @@ function TaskDragOverlayCard({ task }: { task: TaskCard }) {
|
||||
{task.title}
|
||||
</h3>
|
||||
</div>
|
||||
{task.description ? (
|
||||
<p className="app-text-muted mt-1 line-clamp-2 text-xs">
|
||||
{task.description}
|
||||
</p>
|
||||
) : null}
|
||||
{task.description ? <TaskDescriptionPreview description={task.description} /> : null}
|
||||
</div>
|
||||
<span className={`shrink-0 whitespace-nowrap text-right text-[11px] font-medium ${priority.textClassName}`}>
|
||||
{priority.urgencyLabel} срочность
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user