Improve employee stats and task card text

This commit is contained in:
lizerk 2026-07-12 21:22:55 +03:00
parent 2dfc628e86
commit 01490aa20b
8 changed files with 595 additions and 16 deletions

View File

@ -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 — частичное обновление."""

View File

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

View File

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

View File

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

View File

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

View File

@ -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} срочность

View File

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

View File

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