From 3a20c3d86aca4d5f8b89cb6d50d10d8b421322df Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 29 Apr 2026 09:54:11 +0300 Subject: [PATCH] Normalize personnel events and attendance state --- backend/api/v1/attendance/serializers.py | 3 +- .../api/v1/employees/serializers/employee.py | 26 ++ .../api/v1/employees/serializers/shared.py | 51 ++- backend/api/v1/employees/views/actions.py | 93 +++++- backend/api/v1/employees/views/employees.py | 8 +- backend/api/v1/requests_app/serializers.py | 7 +- backend/api/v1/requests_app/validators.py | 13 +- backend/api/v1/requests_app/views.py | 7 + backend/attendance/services.py | 136 +++++--- backend/employees/admin.py | 2 +- backend/employees/constants.py | 79 ++++- .../migrations/0050_employeeaction_date_to.py | 122 +++++++ .../0051_drop_legacy_state_tables.py | 17 + backend/employees/models.py | 30 +- backend/employees/services/personnel_state.py | 204 ++++++++++++ backend/employees/services/request_actions.py | 5 + backend/employees/signals/common.py | 44 ++- backend/requests_app/enums.py | 1 + .../commands/migrate_requests_to_actions.py | 78 +---- .../0012_add_maternity_request_type.py | 32 ++ backend/requests_app/models.py | 1 + backend/requests_app/tasks.py | 144 +------- backend/requests_app/tests/test_signals.py | 65 +++- .../v1/attendance/test_attendance_storage.py | 153 ++++++++- .../api/v1/employees/test_employee_actions.py | 223 ++++++++++++- .../api/v1/requests_app/test_requests_api.py | 62 +++- .../tests/employees/test_personnel_state.py | 204 ++++++++++++ .../requests_app/test_request_actions.py | 58 +++- frontend/src/app/attendance/page.tsx | 308 ++++++++++++++++-- frontend/src/app/profile/page.tsx | 11 +- frontend/src/app/users/[id]/page.tsx | 13 +- .../attendance/AttendanceDayEventsModal.tsx | 109 ++++--- .../AttendanceRecordCommentsModal.tsx | 132 ++++++-- .../attendance/AttendanceRecordHeader.tsx | 83 +++++ .../attendance/EmployeeAttendanceCard.tsx | 246 +++++++++++++- .../requests/RequestStatisticsPanel.tsx | 18 +- .../components/users/EmployeeActionModal.tsx | 29 +- .../users/EmployeeActionsTimeline.tsx | 51 ++- .../src/components/users/ProfileSections.tsx | 62 +++- frontend/src/hooks/useRequestsPage.ts | 3 + frontend/src/hooks/useUserDetailPage.ts | 18 +- frontend/src/lib/api/employees.ts | 4 +- frontend/src/lib/users/userDetailUtils.ts | 21 +- frontend/src/types/api.ts | 31 +- 44 files changed, 2511 insertions(+), 496 deletions(-) create mode 100644 backend/employees/migrations/0050_employeeaction_date_to.py create mode 100644 backend/employees/migrations/0051_drop_legacy_state_tables.py create mode 100644 backend/employees/services/personnel_state.py create mode 100644 backend/requests_app/migrations/0012_add_maternity_request_type.py create mode 100644 backend/tests/employees/test_personnel_state.py create mode 100644 frontend/src/components/attendance/AttendanceRecordHeader.tsx diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py index fdb9e6e6..27897806 100644 --- a/backend/api/v1/attendance/serializers.py +++ b/backend/api/v1/attendance/serializers.py @@ -9,6 +9,7 @@ from attendance.models import ( ) from attendance.services import get_standard_work_schedule_payload from api.v1.employees.serializers import EmployeeBriefSerializer +from employees.constants import ACTION_REMOTE class DateOverrideSerializer(serializers.Serializer): @@ -349,7 +350,7 @@ class AttendanceRecordSerializer(serializers.ModelSerializer): def get_non_working_reason(self, obj): if obj.effective_is_workday: return "" - if obj.personnel_status_label: + if obj.personnel_status_label and obj.personnel_status != ACTION_REMOTE: return obj.personnel_status_label if not obj.is_workday: return "Выходной по графику/календарю" diff --git a/backend/api/v1/employees/serializers/employee.py b/backend/api/v1/employees/serializers/employee.py index 5c26e3e9..c2512b49 100644 --- a/backend/api/v1/employees/serializers/employee.py +++ b/backend/api/v1/employees/serializers/employee.py @@ -6,7 +6,9 @@ from typing import Any, Dict from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema_field from django.contrib.auth import get_user_model +from django.utils import timezone from employees.models import EmployeeDepartment, Position, Skill +from employees.services.personnel_state import resolve_employee_personnel_state from eusrr_backend.auth_backends import PHONE_FIELD as DETECTED_PHONE_FIELD from rest_framework import serializers @@ -37,6 +39,15 @@ class EmployeeDepartmentRelationSerializer(serializers.Serializer): role_names = serializers.ListField(child=serializers.CharField()) +class EmployeePersonnelStateSerializer(serializers.Serializer): + status = serializers.CharField() + label = serializers.CharField(allow_blank=True) + action_id = serializers.IntegerField(allow_null=True) + date_from = serializers.DateField(allow_null=True) + date_to = serializers.DateField(allow_null=True) + expects_attendance = serializers.BooleanField() + + class EmployeeSerializer(serializers.ModelSerializer): """Полная версия сотрудника для /employees/.""" @@ -61,6 +72,7 @@ class EmployeeSerializer(serializers.ModelSerializer): departments = serializers.SerializerMethodField() auth = serializers.SerializerMethodField(read_only=True) + personnel_state = serializers.SerializerMethodField(read_only=True) class Meta: model = Employee @@ -93,6 +105,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "last_activity_at", "date_joined", "auth", + "personnel_state", ) read_only_fields = ( "is_ldap_managed", @@ -104,6 +117,7 @@ class EmployeeSerializer(serializers.ModelSerializer): "last_activity_at", "date_joined", "auth", + "personnel_state", ) extra_kwargs = { "password": {"write_only": True, "required": False}, @@ -245,6 +259,18 @@ class EmployeeSerializer(serializers.ModelSerializer): "permissions_by_app": perms_by_app, } + @extend_schema_field(EmployeePersonnelStateSerializer()) + def get_personnel_state(self, obj): + state = resolve_employee_personnel_state(obj, timezone.localdate()) + return { + "status": state.status, + "label": state.label or "Работает", + "action_id": state.action_id, + "date_from": state.date_from, + "date_to": state.date_to, + "expects_attendance": state.expects_attendance, + } + class EmployeeBriefSerializer(serializers.ModelSerializer): display_name = serializers.SerializerMethodField() diff --git a/backend/api/v1/employees/serializers/shared.py b/backend/api/v1/employees/serializers/shared.py index 9d07a950..8ad54e61 100644 --- a/backend/api/v1/employees/serializers/shared.py +++ b/backend/api/v1/employees/serializers/shared.py @@ -2,7 +2,7 @@ from drf_spectacular.utils import extend_schema_field from django.contrib.auth.models import Group -from employees.constants import ACTION_CHOICES +from employees.constants import ACTION_CHOICES, TEMPORARY_START_ACTIONS from employees.models import EmployeeAction, Position, Skill from rest_framework import serializers @@ -64,12 +64,57 @@ class EmployeeActionSerializer(serializers.ModelSerializer): "action", "action_display", "date", + "date_to", "date_display", "comment", "extra", "history", ] + def validate(self, attrs): + action = attrs.get( + "action", + getattr(self.instance, "action", None), + ) + date = attrs.get("date", getattr(self.instance, "date", None)) + date_to = attrs.get("date_to", getattr(self.instance, "date_to", None)) + + if date_to is not None and action not in TEMPORARY_START_ACTIONS: + raise serializers.ValidationError( + { + "date_to": ( + "Дата окончания доступна только для временных " + "кадровых событий." + ) + } + ) + + if ( + action in TEMPORARY_START_ACTIONS + and date_to is None + and ( + self.instance is None + or "action" in attrs + or "date" in attrs + or "date_to" in attrs + ) + ): + raise serializers.ValidationError( + { + "date_to": ( + "Дата окончания обязательна для временных " + "кадровых событий." + ) + } + ) + + if date and date_to and date_to < date.date(): + raise serializers.ValidationError( + {"date_to": "Дата окончания не может быть раньше даты начала."} + ) + + return attrs + @extend_schema_field(serializers.CharField()) def get_action_display(self, obj): return obj.get_action_display() @@ -91,7 +136,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): action_labels = dict(ACTION_CHOICES) history_type_labels = {"+": "Создано", "~": "Изменено", "-": "Удалено"} - diff_fields = ["action", "comment", "extra"] + diff_fields = ["action", "date", "date_to", "comment", "extra"] out = [] for i, cur in enumerate(items): @@ -102,7 +147,7 @@ class EmployeeActionSerializer(serializers.ModelSerializer): new = getattr(cur, name, None) old = getattr(prev, name, None) if prev else None - if name == "date": + if name in {"date", "date_to"}: new_fmt = new.isoformat() if new else None old_fmt = old.isoformat() if old else None if new_fmt != old_fmt: diff --git a/backend/api/v1/employees/views/actions.py b/backend/api/v1/employees/views/actions.py index b63ece00..3aa7f6da 100644 --- a/backend/api/v1/employees/views/actions.py +++ b/backend/api/v1/employees/views/actions.py @@ -4,10 +4,15 @@ from __future__ import annotations import logging -from django.db import transaction +from django.db import connection, transaction from django.utils import timezone -from employees.constants import ACTION_DISMISSED -from employees.models import Department, EmployeeAction, EmployeeDepartment +from employees.constants import ( + ACTION_DISMISSED, + ACTIVATING_MARKER_ACTIONS, + PERMANENT_ACTIONS, +) +from employees.models import Department, Employee, EmployeeAction, EmployeeDepartment +from employees.services.personnel_state import resolve_employee_personnel_state from rest_framework import filters, viewsets from rest_framework.permissions import IsAuthenticated @@ -38,7 +43,14 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): ordering_fields = ["date", "id"] ordering = ["-date"] pagination_class = None - history_diff_fields = ["action", "date", "comment", "extra", "employee_id"] + history_diff_fields = [ + "action", + "date", + "date_to", + "comment", + "extra", + "employee_id", + ] def get_queryset(self): qs = EmployeeAction.objects.select_related("employee").order_by( @@ -81,15 +93,29 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): # --- бизнес-эффекты после записи события --- def _apply_effects(self, action_obj: EmployeeAction): - """Применяет эффекты кадрового события. + """Синхронизирует эффекты аккаунта по текущему кадровому состоянию. Работает только с БД. LDAP синхронизация через сигналы: - sync_employee_to_ldap_on_save: активация/деактивация в LDAP - sync_department_member_to_ldap: удаление из отделов в LDAP """ - emp = action_obj.employee + if not self._action_affects_account(action_obj.action): + return - if action_obj.action == ACTION_DISMISSED: + self._sync_employee_account_state(action_obj.employee) + + @staticmethod + def _action_affects_account(action: str) -> bool: + return action in PERMANENT_ACTIONS or action in ACTIVATING_MARKER_ACTIONS + + def _sync_employee_account_state(self, emp: Employee): + today = timezone.localdate() + current_state = resolve_employee_personnel_state(emp, today) + should_be_active = current_state.status != ACTION_DISMISSED + + emp.refresh_from_db(fields=["is_active"]) + + if not should_be_active: # Снимаем с должности руководителя (до деактивации!) headed_depts = list(Department.objects.filter(head=emp)) if headed_depts: @@ -113,7 +139,6 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): # Деактивируем связи с отделами (сигналы синхронизируют в LDAP) # ВАЖНО: используем save() для каждой записи, чтобы сработали # сигналы - today = timezone.now().date() for emp_dept in EmployeeDepartment.objects.filter( employee=emp, is_active=True ): @@ -121,8 +146,9 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): emp_dept.date_to = today emp_dept.save(update_fields=["is_active", "date_to"]) else: - # Любое иное событие делает сотрудника активным - # (сигнал синхронизирует в LDAP) + # Активность учетной записи определяется текущим постоянным + # кадровым состоянием; временные события сами по себе не меняют + # LDAP/DB-активацию. if not emp.is_active: emp.is_active = True emp._ldap_changes = {"is_active": True} @@ -133,6 +159,13 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): # отделы self._ensure_ldap_dn_location(emp) + def _sync_employee_account_state_by_id(self, employee_id: int | None): + if not employee_id: + return + emp = Employee.objects.filter(id=employee_id).first() + if emp: + self._sync_employee_account_state(emp) + @staticmethod def _ensure_ldap_dn_location(emp): """Гарантирует, что LDAP DN сотрудника в правильном OU. @@ -232,5 +265,43 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): @transaction.atomic def perform_update(self, serializer): + old_employee_id = serializer.instance.employee_id + old_action = serializer.instance.action obj = serializer.save() - self._apply_effects(obj) + affected_employee_ids = set() + if self._action_affects_account(old_action): + affected_employee_ids.add(old_employee_id) + if self._action_affects_account(obj.action): + affected_employee_ids.add(obj.employee_id) + for employee_id in affected_employee_ids: + self._sync_employee_account_state_by_id(employee_id) + + @transaction.atomic + def perform_destroy(self, instance): + employee_id = instance.employee_id + action = instance.action + self._clear_legacy_state_references(instance.id) + super().perform_destroy(instance) + if self._action_affects_account(action): + self._sync_employee_account_state_by_id(employee_id) + + @staticmethod + def _clear_legacy_state_references(action_id: int): + """Remove stale references from abandoned normalized-state tables.""" + legacy_tables = ( + "employees_employeebasestatusevent", + "employees_employeestatusinterval", + ) + existing_tables = set(connection.introspection.table_names()) + tables_to_clean = [ + table for table in legacy_tables if table in existing_tables + ] + if not tables_to_clean: + return + + with connection.cursor() as cursor: + for table in tables_to_clean: + cursor.execute( + f'DELETE FROM "{table}" WHERE source_action_id = %s', + [action_id], + ) diff --git a/backend/api/v1/employees/views/employees.py b/backend/api/v1/employees/views/employees.py index 636fe42d..cacccb88 100644 --- a/backend/api/v1/employees/views/employees.py +++ b/backend/api/v1/employees/views/employees.py @@ -10,7 +10,11 @@ import logging from common.emails import send_templated_mail from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery from django.utils.crypto import get_random_string -from employees.constants import ACTION_DISMISSED +from employees.constants import ( + ACTION_DISMISSED, + ACTIVATING_MARKER_ACTIONS, + PERMANENT_ACTIONS, +) from employees.models import ( Department, EmployeeAction, @@ -84,8 +88,10 @@ class EmployeeViewSet(viewsets.ModelViewSet): return [IsAuthenticated()] def get_queryset(self): + status_action_codes = PERMANENT_ACTIONS | ACTIVATING_MARKER_ACTIONS last_action_code_sq = Subquery( EmployeeAction.objects.filter(employee_id=OuterRef("pk")) + .filter(action__in=status_action_codes) .order_by("-date") .values("action")[:1] ) diff --git a/backend/api/v1/requests_app/serializers.py b/backend/api/v1/requests_app/serializers.py index b649b4a6..01f0ba27 100644 --- a/backend/api/v1/requests_app/serializers.py +++ b/backend/api/v1/requests_app/serializers.py @@ -384,7 +384,12 @@ class RequestWriteSerializer(serializers.ModelSerializer): {"recipient_ids": "Автор не может быть получателем"} ) - if type_ in (RequestType.VACATION, RequestType.SICK_LEAVE): + if type_ in ( + RequestType.VACATION, + RequestType.SICK_LEAVE, + RequestType.DAY_OFF, + RequestType.MATERNITY, + ): if not (date_from and date_to): raise serializers.ValidationError( { diff --git a/backend/api/v1/requests_app/validators.py b/backend/api/v1/requests_app/validators.py index 987de2fe..fbe9f80c 100644 --- a/backend/api/v1/requests_app/validators.py +++ b/backend/api/v1/requests_app/validators.py @@ -5,7 +5,7 @@ from typing import Any, Mapping from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError -# VACATION/SICK_LEAVE/TRANSFER/DISMISSAL/OTHER +# VACATION/SICK_LEAVE/DAY_OFF/MATERNITY/TRANSFER/DISMISSAL/OTHER from requests_app.enums import RequestType from requests_app.enums import RequestStatus @@ -16,8 +16,8 @@ class RequestDatesByTypeValidator: Правила: - Проверяем ТОЛЬКО если в payload присутствуют date_from/date_to ИЛИ меняется type. - - Для VACATION/SICK_LEAVE: если хотя бы одна дата передана, - требуются обе; date_to >= date_from. + - Для VACATION/SICK_LEAVE/DAY_OFF/MATERNITY: если хотя бы одна + дата передана, требуются обе; date_to >= date_from. - Для TRANSFER/DISMISSAL: если даты трогают — обязателен date_from. - Для OTHER: дат можно не указывать. """ @@ -57,7 +57,12 @@ class RequestDatesByTypeValidator: if not (touches_dates or touches_type): return - if type_ in (RequestType.VACATION, RequestType.SICK_LEAVE): + if type_ in ( + RequestType.VACATION, + RequestType.SICK_LEAVE, + RequestType.DAY_OFF, + RequestType.MATERNITY, + ): # Если пользователь прислал хотя бы одно из полей даты — должны быть # обе if touches_dates and (not date_from or not date_to): diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py index 2d95079b..86d9bcf1 100644 --- a/backend/api/v1/requests_app/views.py +++ b/backend/api/v1/requests_app/views.py @@ -182,6 +182,7 @@ def _build_employee_statistics( sick_leave_days = 0 day_off_days = 0 + maternity_days = 0 paid_vacation_days = 0 unpaid_vacation_days = 0 @@ -197,6 +198,8 @@ def _build_employee_statistics( sick_leave_days += duration elif req.type == RequestType.DAY_OFF: day_off_days += duration + elif req.type == RequestType.MATERNITY: + maternity_days += duration elif req.type == RequestType.VACATION: if _is_unpaid_vacation(req): unpaid_vacation_days += duration @@ -216,8 +219,12 @@ def _build_employee_statistics( "day_off_requests_count": base_qs.filter( type=RequestType.DAY_OFF ).count(), + "maternity_requests_count": base_qs.filter( + type=RequestType.MATERNITY + ).count(), "sick_leave_days": sick_leave_days, "day_off_days": day_off_days, + "maternity_days": maternity_days, "paid_vacation_days": paid_vacation_days, "unpaid_vacation_days": unpaid_vacation_days, } diff --git a/backend/attendance/services.py b/backend/attendance/services.py index 0a7aaaf5..2494cb03 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -1,7 +1,6 @@ from __future__ import annotations from io import BytesIO -from dataclasses import dataclass from datetime import date, timedelta from calendar import monthrange from typing import Any @@ -28,10 +27,14 @@ from employees.constants import ( ACTION_ON_LEAVE, ACTION_ON_MATERNITY, ACTION_ON_SICK_LEAVE, + ACTION_REMOTE, ) from employees.models import Employee - -PERSONNEL_STATUS_NORMAL = "normal" +from employees.services.personnel_state import ( + EmployeePersonnelState, + PERSONNEL_STATUS_NORMAL, + resolve_employee_personnel_state, +) MONTHS_RU = { 1: "Январь", @@ -88,6 +91,7 @@ PERSONNEL_STATUS_SHORT_LABELS = { ACTION_ON_DAY_OFF: "ОТГ", ACTION_ON_MATERNITY: "ДЕКР", ACTION_DISMISSED: "Вне штата", + ACTION_REMOTE: "УД", } PERSONNEL_STATUS_LEGEND = { @@ -96,6 +100,7 @@ PERSONNEL_STATUS_LEGEND = { ACTION_ON_DAY_OFF: "Отгул", ACTION_ON_MATERNITY: "Декрет", ACTION_DISMISSED: "Уволен / вне штата", + ACTION_REMOTE: "Удаленка", } MATRIX_STATUS_FILLS = { @@ -110,14 +115,6 @@ MATRIX_STATUS_FILLS = { "normal": "E0F2FE", } -NON_WORKING_PERSONNEL_ACTIONS = { - ACTION_ON_LEAVE, - ACTION_ON_SICK_LEAVE, - ACTION_ON_DAY_OFF, - ACTION_ON_MATERNITY, - ACTION_DISMISSED, -} - SUPPRESSED_EMPLOYEE_ISSUE_MARKERS = ( "absence", "absent", @@ -141,14 +138,6 @@ DEFAULT_WORK_SCHEDULE_PAYLOAD = { } -@dataclass(frozen=True) -class PersonnelDayState: - status: str = PERSONNEL_STATUS_NORMAL - label: str = "" - action_id: int | None = None - is_non_working: bool = False - - def save_logstorm_attendance_result( *, employee: Employee, @@ -699,31 +688,22 @@ def _format_export_number(value: Any) -> str: def resolve_employee_day_state( personnel_actions, record_date: date, -) -> PersonnelDayState: - day_actions = [ - action - for action in personnel_actions - if action.date and action.date.date() <= record_date - ] - if not day_actions: - return PersonnelDayState() - - action = day_actions[-1] - if action.action not in NON_WORKING_PERSONNEL_ACTIONS: - return PersonnelDayState(action_id=action.id) - - return PersonnelDayState( - status=action.action, - label=action.get_action_display(), - action_id=action.id, - is_non_working=True, +) -> EmployeePersonnelState: + employee = personnel_actions[0].employee if personnel_actions else None + if employee is None: + return EmployeePersonnelState() + return resolve_employee_personnel_state( + employee, + record_date, + actions=personnel_actions, ) def build_attendance_record_defaults( raw_record: dict[str, Any], - personnel_state: PersonnelDayState, + personnel_state: EmployeePersonnelState, ) -> dict[str, Any]: + is_remote_personnel_state = personnel_state.status == ACTION_REMOTE work_hours = _nullable_float(raw_record.get("work_hours")) has_work = bool( _nullable_string(raw_record.get("arrival_time")) @@ -757,6 +737,7 @@ def build_attendance_record_defaults( if ( effective_is_workday + and not is_remote_personnel_state and not has_work and not is_absent and _has_absence_issue([*statuses, *employee_issues]) @@ -767,6 +748,18 @@ def build_attendance_record_defaults( is_underwork = False underwork_hours = None + if is_remote_personnel_state: + is_late = False + late_minutes = None + is_early_leave = False + early_leave_minutes = None + is_underwork = False + underwork_hours = None + is_absent = False + statuses = _suppress_personnel_non_working_issues(statuses) + employee_issues = _suppress_personnel_non_working_issues(employee_issues) + technical_issues = _suppress_personnel_non_working_issues(technical_issues) + if not effective_is_workday: is_late = False late_minutes = None @@ -805,9 +798,21 @@ def build_attendance_record_defaults( "statuses": statuses, "employee_issues": employee_issues, "technical_issues": technical_issues, - "personnel_status": personnel_state.status, - "personnel_status_label": personnel_state.label, - "personnel_action_id": personnel_state.action_id, + "personnel_status": ( + personnel_state.status + if personnel_state.is_non_working or is_remote_personnel_state + else PERSONNEL_STATUS_NORMAL + ), + "personnel_status_label": ( + personnel_state.label + if personnel_state.is_non_working or is_remote_personnel_state + else "" + ), + "personnel_action_id": ( + personnel_state.action_id + if personnel_state.is_non_working or is_remote_personnel_state + else None + ), "is_manually_edited": bool(raw_record.get("manual_edited", False)), "manual_edit_payload": ( raw_record.get("manual_edit_payload") @@ -990,6 +995,9 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]: issues=issues, comments_count=comments_count, ) + raw_non_working_reason = record.raw_data.get("non_working_reason") + if _is_remote_calendar_non_working_day(record): + raw_non_working_reason = "" return { "record_id": record.id, "arrival_time": record.arrival_time, @@ -1001,7 +1009,7 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]: "issues": issues, "is_workday": record.is_workday, "effective_is_workday": record.effective_is_workday, - "non_working_reason": record.raw_data.get("non_working_reason") + "non_working_reason": raw_non_working_reason or _record_non_working_reason(record), "is_late": record.is_late, "is_early_leave": record.is_early_leave, @@ -1041,7 +1049,10 @@ def _monthly_matrix_cell_presentation( def _matrix_primary_label(record: AttendanceRecord, status: str) -> str: - if record.personnel_status != PERSONNEL_STATUS_NORMAL: + if ( + record.personnel_status != PERSONNEL_STATUS_NORMAL + and not _is_remote_calendar_non_working_day(record) + ): return ( PERSONNEL_STATUS_LEGEND.get(record.personnel_status) or record.personnel_status_label @@ -1053,7 +1064,10 @@ def _matrix_primary_label(record: AttendanceRecord, status: str) -> str: def _matrix_short_label(record: AttendanceRecord, status: str) -> str: - if record.personnel_status != PERSONNEL_STATUS_NORMAL: + if ( + record.personnel_status != PERSONNEL_STATUS_NORMAL + and not _is_remote_calendar_non_working_day(record) + ): return ( PERSONNEL_STATUS_SHORT_LABELS.get(record.personnel_status) or record.personnel_status_label @@ -1071,15 +1085,24 @@ def _matrix_display_text( status: str, short_label: str, ) -> str: + time_part = ( + f"{_format_matrix_time(record.arrival_time)}/" + f"{_format_matrix_time(record.departure_time)}" + ) + if not record.effective_is_workday: if record.work_hours: return f"{short_label} + {_format_export_number(record.work_hours)}ч" return short_label - time_part = ( - f"{_format_matrix_time(record.arrival_time)}/" - f"{_format_matrix_time(record.departure_time)}" - ) + if record.personnel_status == ACTION_REMOTE: + if record.arrival_time or record.departure_time or record.work_hours: + return ( + f"{short_label} · {time_part} · " + f"{_format_export_number(record.work_hours)}ч" + ) + return short_label + if status == "normal": return f"{time_part} · {_format_export_number(record.work_hours)}ч" if status == "late": @@ -1135,7 +1158,10 @@ def _matrix_detail_lines( f"Рабочий день: {'да' if record.effective_is_workday else 'нет'}", ] if record.personnel_status != PERSONNEL_STATUS_NORMAL: - lines.append(f"Кадровое событие: {_matrix_primary_label(record, status)}") + lines.append( + "Кадровое событие: " + f"{record.personnel_status_label or _matrix_primary_label(record, status)}" + ) elif not record.effective_is_workday: reason = _record_non_working_reason(record) if reason: @@ -1214,7 +1240,7 @@ def _non_working_reason( *, is_workday: bool, effective_is_workday: bool, - personnel_state: PersonnelDayState, + personnel_state: EmployeePersonnelState, ) -> str: if effective_is_workday: return "" @@ -1228,13 +1254,21 @@ def _non_working_reason( def _record_non_working_reason(record: AttendanceRecord) -> str: if record.effective_is_workday: return "" - if record.personnel_status_label: + if record.personnel_status_label and record.personnel_status != ACTION_REMOTE: return record.personnel_status_label if not record.is_workday: return "Выходной по графику/календарю" return "Нерабочий день" +def _is_remote_calendar_non_working_day(record: AttendanceRecord) -> bool: + return bool( + record.personnel_status == ACTION_REMOTE + and not record.effective_is_workday + and not record.is_workday + ) + + def _is_valid_workday_record(record: AttendanceRecord) -> bool: return bool(record.effective_is_workday and not record.technical_issues) diff --git a/backend/employees/admin.py b/backend/employees/admin.py index bbd59978..73572373 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -1190,7 +1190,7 @@ class PositionAdmin(SimpleHistoryAdmin): @admin.register(EmployeeAction) class EmployeeActionAdmin(SimpleHistoryAdmin): - list_display = ("employee", "action", "date") + list_display = ("employee", "action", "date", "date_to") list_filter = ("action",) search_fields = ("employee__first_name", "employee__last_name", "comment") autocomplete_fields = ("employee",) diff --git a/backend/employees/constants.py b/backend/employees/constants.py index bfd2ddb4..eb6991ae 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -6,6 +6,8 @@ GENDER_CHOICES = ( ) +ACTION_WORKING = "working" +ACTION_REMOTE = "remote" ACTION_HIRED = "hired" ACTION_DISMISSED = "dismissed" ACTION_ON_LEAVE = "on_leave" @@ -19,20 +21,69 @@ ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity" ACTION_TRANSFERRED = "transferred" ACTION_REHIRED = "rehired" -ACTION_CHOICES = [ - (ACTION_HIRED, "Принят"), - (ACTION_DISMISSED, "Уволен"), - (ACTION_ON_LEAVE, "В отпуске"), - (ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"), - (ACTION_ON_SICK_LEAVE, "На больничном"), - (ACTION_RETURNED_FROM_SICK_LEAVE, "Вернулся с больничного"), - (ACTION_ON_DAY_OFF, "В отгуле"), - (ACTION_RETURNED_FROM_DAY_OFF, "Вернулся из отгула"), - (ACTION_ON_MATERNITY, "В декрете"), - (ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"), - (ACTION_TRANSFERRED, "Переведен"), - (ACTION_REHIRED, "Восстановлен"), -] +ACTION_LABELS = { + ACTION_WORKING: "Работает", + ACTION_REMOTE: "На удалёнке", + ACTION_HIRED: "Принят", + ACTION_DISMISSED: "Уволен", + ACTION_ON_LEAVE: "В отпуске", + ACTION_RETURNED_FROM_LEAVE: "Вернулся из отпуска", + ACTION_ON_SICK_LEAVE: "На больничном", + ACTION_RETURNED_FROM_SICK_LEAVE: "Вернулся с больничного", + ACTION_ON_DAY_OFF: "В отгуле", + ACTION_RETURNED_FROM_DAY_OFF: "Вернулся из отгула", + ACTION_ON_MATERNITY: "В декрете", + ACTION_RETURNED_FROM_MATERNITY: "Вернулся из декрета", + ACTION_TRANSFERRED: "Переведен", + ACTION_REHIRED: "Восстановлен", +} + +PERMANENT_ACTIONS = { + ACTION_WORKING, + ACTION_REMOTE, + ACTION_DISMISSED, +} + +PERIOD_ACTIONS = { + ACTION_ON_LEAVE, + ACTION_ON_SICK_LEAVE, + ACTION_ON_DAY_OFF, + ACTION_ON_MATERNITY, +} + +MARKER_ACTIONS = { + ACTION_HIRED, + ACTION_TRANSFERRED, + ACTION_REHIRED, + ACTION_RETURNED_FROM_LEAVE, + ACTION_RETURNED_FROM_SICK_LEAVE, + ACTION_RETURNED_FROM_DAY_OFF, + ACTION_RETURNED_FROM_MATERNITY, +} + +ACTIVATING_MARKER_ACTIONS = { + ACTION_HIRED, + ACTION_REHIRED, +} + +RETURN_MARKER_ACTIONS = { + ACTION_RETURNED_FROM_LEAVE, + ACTION_RETURNED_FROM_SICK_LEAVE, + ACTION_RETURNED_FROM_DAY_OFF, + ACTION_RETURNED_FROM_MATERNITY, +} + +TEMPORARY_START_ACTIONS = PERIOD_ACTIONS +TEMPORARY_RETURN_ACTIONS = RETURN_MARKER_ACTIONS + +TEMPORARY_ACTION_PRIORITIES = { + ACTION_ON_SICK_LEAVE: 0, + ACTION_ON_MATERNITY: 1, + ACTION_ON_LEAVE: 2, + ACTION_ON_DAY_OFF: 3, +} + +ACTION_CHOICES = [(action, ACTION_LABELS[action]) for action in ACTION_LABELS] class DeptPerm: diff --git a/backend/employees/migrations/0050_employeeaction_date_to.py b/backend/employees/migrations/0050_employeeaction_date_to.py new file mode 100644 index 00000000..57322e80 --- /dev/null +++ b/backend/employees/migrations/0050_employeeaction_date_to.py @@ -0,0 +1,122 @@ +# Generated by Codex on 2026-04-28 + +from datetime import timedelta + +from django.db import migrations, models + + +TEMPORARY_ACTION_RETURN_MAP = { + "on_leave": "returned_from_leave", + "on_sick_leave": "returned_from_sick_leave", + "on_day_off": "returned_from_day_off", + "on_maternity": "returned_from_maternity", +} + +ACTION_CHOICES = [ + ("working", "Работает"), + ("remote", "На удалёнке"), + ("hired", "Принят"), + ("dismissed", "Уволен"), + ("on_leave", "В отпуске"), + ("returned_from_leave", "Вернулся из отпуска"), + ("on_sick_leave", "На больничном"), + ("returned_from_sick_leave", "Вернулся с больничного"), + ("on_day_off", "В отгуле"), + ("returned_from_day_off", "Вернулся из отгула"), + ("on_maternity", "В декрете"), + ("returned_from_maternity", "Вернулся из декрета"), + ("transferred", "Переведен"), + ("rehired", "Восстановлен"), +] + + +def backfill_date_to(apps, schema_editor): + EmployeeAction = apps.get_model("employees", "EmployeeAction") + + for action in ( + EmployeeAction.objects.filter( + action__in=TEMPORARY_ACTION_RETURN_MAP.keys(), + date_to__isnull=True, + ) + .select_related("source_request") + .order_by("employee_id", "date", "id") + ): + date_to = None + if action.source_request_id and action.source_request.date_to: + date_to = action.source_request.date_to + else: + return_action = _find_return_action(EmployeeAction, action) + if return_action and return_action.date: + date_to = return_action.date.date() - timedelta(days=1) + + if date_to is not None: + action.date_to = date_to + action.save(update_fields=["date_to"]) + + +def _find_return_action(EmployeeAction, action): + return_action = TEMPORARY_ACTION_RETURN_MAP[action.action] + qs = EmployeeAction.objects.filter( + employee_id=action.employee_id, + action=return_action, + date__gte=action.date, + ) + if action.source_request_id: + matched = ( + qs.filter(source_request_id=action.source_request_id) + .order_by("date", "id") + .first() + ) + if matched: + return matched + return qs.order_by("date", "id").first() + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0049_employeeaction_source_request"), + ] + + operations = [ + migrations.AddField( + model_name="employeeaction", + name="date_to", + field=models.DateField( + blank=True, + null=True, + verbose_name="Дата окончания временного события", + ), + ), + migrations.AddField( + model_name="historicalemployeeaction", + name="date_to", + field=models.DateField( + blank=True, + null=True, + verbose_name="Дата окончания временного события", + ), + ), + migrations.AlterField( + model_name="employeeaction", + name="action", + field=models.CharField( + choices=ACTION_CHOICES, + max_length=50, + verbose_name="Кадровое событие", + ), + ), + migrations.AlterField( + model_name="historicalemployeeaction", + name="action", + field=models.CharField( + choices=ACTION_CHOICES, + max_length=50, + verbose_name="Кадровое событие", + ), + ), + migrations.RunPython(backfill_date_to, reverse_code=noop_reverse), + ] diff --git a/backend/employees/migrations/0051_drop_legacy_state_tables.py b/backend/employees/migrations/0051_drop_legacy_state_tables.py new file mode 100644 index 00000000..c747bf77 --- /dev/null +++ b/backend/employees/migrations/0051_drop_legacy_state_tables.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("employees", "0050_employeeaction_date_to"), + ] + + operations = [ + migrations.RunSQL( + sql=[ + "DROP TABLE IF EXISTS employees_employeebasestatusevent", + "DROP TABLE IF EXISTS employees_employeestatusinterval", + ], + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index c09d1864..13705784 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -279,10 +279,15 @@ class Employee(AbstractUser): @property def employment_status(self): - last_action = self.actions.order_by("-date").first() - if last_action: - return last_action.get_action_display() - return "Нет данных" + from employees.services.personnel_state import ( + PERSONNEL_STATUS_NORMAL, + resolve_employee_personnel_state, + ) + + state = resolve_employee_personnel_state(self, timezone.localdate()) + if state.status == PERSONNEL_STATUS_NORMAL: + return "Работает" + return state.label or "Нет данных" @property def departments(self): @@ -302,10 +307,16 @@ class Employee(AbstractUser): def is_actually_active(self): if not self.email_verified: return False # Неактивен без подтверждения email - last_action = self.actions.order_by("-date").first() - if not last_action: + + from employees.services.personnel_state import ( + PERSONNEL_STATUS_NORMAL, + resolve_employee_personnel_state, + ) + + state = resolve_employee_personnel_state(self, timezone.localdate()) + if state.status == PERSONNEL_STATUS_NORMAL: return self.is_active - return last_action.action != ACTION_DISMISSED + return state.status != ACTION_DISMISSED def verify_email(self, code): if self.email_activation_code == code: @@ -346,6 +357,11 @@ class EmployeeAction(models.Model): verbose_name="Источник-заявление", ) date = models.DateTimeField("Дата действия") + date_to = models.DateField( + "Дата окончания временного события", + null=True, + blank=True, + ) comment = models.TextField("Комментарий/причина", blank=True) extra = models.JSONField("Дополнительно", blank=True, null=True) history = HistoricalRecords() diff --git a/backend/employees/services/personnel_state.py b/backend/employees/services/personnel_state.py new file mode 100644 index 00000000..5480fd4a --- /dev/null +++ b/backend/employees/services/personnel_state.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from typing import Iterable + +from employees.constants import ( + ACTION_DISMISSED, + ACTION_WORKING, + ACTION_LABELS, + ACTIVATING_MARKER_ACTIONS, + MARKER_ACTIONS, + PERMANENT_ACTIONS, + RETURN_MARKER_ACTIONS, + TEMPORARY_ACTION_PRIORITIES, + TEMPORARY_START_ACTIONS, +) +from employees.models import Employee, EmployeeAction + +PERSONNEL_STATUS_NORMAL = "normal" + + +@dataclass(frozen=True) +class EmployeePersonnelState: + status: str = PERSONNEL_STATUS_NORMAL + label: str = "" + action_id: int | None = None + date_from: date | None = None + date_to: date | None = None + expects_attendance: bool = True + + @property + def is_non_working(self) -> bool: + return not self.expects_attendance + + +def resolve_employee_personnel_state( + employee: Employee, + target_date: date, + *, + actions: Iterable[EmployeeAction] | None = None, +) -> EmployeePersonnelState: + employee_actions = _sorted_actions( + actions + if actions is not None + else employee.actions.select_related("source_request").all() + ) + relevant_actions = [ + action + for action in employee_actions + if action.date and action.date.date() <= target_date + ] + if not relevant_actions: + return EmployeePersonnelState() + + permanent_action = _resolve_permanent_action(relevant_actions) + if permanent_action and permanent_action.action == ACTION_DISMISSED: + return EmployeePersonnelState( + status=ACTION_DISMISSED, + label=ACTION_LABELS[ACTION_DISMISSED], + action_id=permanent_action.id, + date_from=permanent_action.date.date(), + expects_attendance=False, + ) + + active_temporary = _active_temporary_actions(employee_actions, target_date) + if active_temporary: + action, date_to = active_temporary[0] + return EmployeePersonnelState( + status=action.action, + label=action.get_action_display(), + action_id=action.id, + date_from=action.date.date(), + date_to=date_to, + expects_attendance=False, + ) + + marker_action = _active_marker_action(relevant_actions, target_date) + if marker_action: + return EmployeePersonnelState( + status=marker_action.action, + label=marker_action.get_action_display(), + action_id=marker_action.id, + date_from=marker_action.date.date(), + expects_attendance=True, + ) + + if permanent_action: + return EmployeePersonnelState( + status=permanent_action.action, + label=permanent_action.get_action_display(), + action_id=permanent_action.id, + date_from=permanent_action.date.date(), + ) + + return EmployeePersonnelState( + status=ACTION_WORKING, + label=ACTION_LABELS[ACTION_WORKING], + ) + + +def _sorted_actions(actions: Iterable[EmployeeAction]) -> list[EmployeeAction]: + return sorted( + actions, + key=lambda action: ( + action.date or datetime.min, + action.id or 0, + ), + ) + + +def _resolve_permanent_action( + actions: Iterable[EmployeeAction], +) -> EmployeeAction | None: + permanent_actions = [ + action for action in actions if action.action in PERMANENT_ACTIONS + ] + if not permanent_actions: + return None + + last_permanent = permanent_actions[-1] + if last_permanent.action != ACTION_DISMISSED: + return last_permanent + + activation_after_dismissal = [ + action + for action in actions + if action.action in ACTIVATING_MARKER_ACTIONS + and action.date + and last_permanent.date + and action.date >= last_permanent.date + ] + if not activation_after_dismissal: + return last_permanent + + restored_state = [ + action + for action in permanent_actions[:-1] + if action.action != ACTION_DISMISSED + ] + return restored_state[-1] if restored_state else None + + +def _active_marker_action( + actions: list[EmployeeAction], + target_date: date, +) -> EmployeeAction | None: + marker_actions = [ + action + for action in actions + if action.action in MARKER_ACTIONS + and action.date + and action.date.date() == target_date + ] + return marker_actions[-1] if marker_actions else None + + +def _active_temporary_actions( + actions: list[EmployeeAction], + target_date: date, +) -> list[tuple[EmployeeAction, date | None]]: + active = [] + for action in actions: + if action.action not in TEMPORARY_START_ACTIONS or not action.date: + continue + + date_from = action.date.date() + if date_from > target_date: + continue + + date_to = _resolve_temporary_action_date_to(action) + if date_to is not None and date_to < target_date: + continue + + active.append((action, date_to)) + + return sorted( + active, + key=lambda item: ( + TEMPORARY_ACTION_PRIORITIES.get(item[0].action, 99), + -(item[0].date.timestamp() if item[0].date else 0), + -(item[0].id or 0), + ), + ) + + +def _resolve_temporary_action_date_to(action: EmployeeAction) -> date | None: + if action.date_to: + return action.date_to + if action.date: + return action.date.date() + return None + + +def is_temporary_start_action(action: str) -> bool: + return action in TEMPORARY_START_ACTIONS + + +def is_temporary_return_action(action: str) -> bool: + return action in RETURN_MARKER_ACTIONS + + +def action_expects_attendance(action: str) -> bool: + return action not in TEMPORARY_START_ACTIONS and action != ACTION_DISMISSED diff --git a/backend/employees/services/request_actions.py b/backend/employees/services/request_actions.py index 702c7108..21b544e2 100644 --- a/backend/employees/services/request_actions.py +++ b/backend/employees/services/request_actions.py @@ -53,6 +53,7 @@ def create_request_action( request, action_type: str, action_date: date | datetime | None, + date_to: date | None = None, comment: str, extra: dict[str, Any] | None = None, ) -> tuple[EmployeeAction, bool]: @@ -78,6 +79,9 @@ def create_request_action( current_extra = legacy.extra if isinstance(legacy.extra, dict) else {} legacy.extra = {**current_extra, "request_id": request.id} updates.append("extra") + if date_to is not None and legacy.date_to is None: + legacy.date_to = date_to + updates.append("date_to") if updates: try: legacy.save(update_fields=updates) @@ -92,6 +96,7 @@ def create_request_action( defaults = { "employee": request.employee, "date": as_action_datetime(action_date), + "date_to": date_to, "comment": comment, "extra": extra_data, } diff --git a/backend/employees/signals/common.py b/backend/employees/signals/common.py index 132a779c..503a0454 100644 --- a/backend/employees/signals/common.py +++ b/backend/employees/signals/common.py @@ -17,6 +17,7 @@ from employees.constants import ( ACTION_HIRED, ACTION_ON_DAY_OFF, ACTION_ON_LEAVE, + ACTION_ON_MATERNITY, ACTION_ON_SICK_LEAVE, ) from employees.models import Employee, EmployeeAction @@ -38,11 +39,12 @@ IMMEDIATE_ACTION_MAPPING = { "dismissal": "dismissed", # Увольнение → Уволен } -# Отложенные события (создаются по Celery в date_from) +# Интервальные события (создаются сразу при одобрении с датами заявки) SCHEDULED_ACTION_MAPPING = { "vacation": ACTION_ON_LEAVE, # Отпуск → В отпуске "sick_leave": ACTION_ON_SICK_LEAVE, # Больничный → На больничном "day_off": ACTION_ON_DAY_OFF, # Отгул → В отгуле + "maternity": ACTION_ON_MATERNITY, # Декрет → В декрете } @@ -84,7 +86,7 @@ def create_action_on_request_approval(sender, instance, created, **kwargs): Две стратегии: 1. НЕМЕДЛЕННО (transfer, dismissal) - сразу при одобрении - 2. ОТЛОЖЕННО (vacation, sick_leave) - через Celery в date_from + 2. ИНТЕРВАЛЬНО (vacation, sick_leave, day_off) - сразу с date_to """ # Пропускаем если это новая заявка if created: @@ -109,9 +111,9 @@ def create_action_on_request_approval(sender, instance, created, **kwargs): if instance.type in IMMEDIATE_ACTION_MAPPING: _create_immediate_action(instance) - # === СТРАТЕГИЯ 2: Отложенное создание через Celery === + # === СТРАТЕГИЯ 2: Интервальное событие сразу после одобрения === elif instance.type in SCHEDULED_ACTION_MAPPING: - _schedule_delayed_action(instance) + _create_interval_action(instance) else: logger.debug( @@ -197,6 +199,40 @@ def _schedule_delayed_action(request): ) +def _create_interval_action(request): + """Создать временное кадровое событие сразу после одобрения заявки.""" + action_type = SCHEDULED_ACTION_MAPPING.get(request.type) + if not action_type: + return + + try: + action, created = create_request_action( + request=request, + action_type=action_type, + action_date=request.date_from, + date_to=request.date_to, + comment=build_request_action_comment(request), + extra={ + "approved_by": (request.approver.id if request.approver else None), + "interval": True, + }, + ) + if not created: + logger.warning(f"EmployeeAction already exists for Request #{request.id}") + return + + logger.info( + f"Created interval EmployeeAction #{action.id} ({action_type}) " + f"from Request #{request.id}" + ) + + except Exception as e: + logger.error( + f"Failed to create interval EmployeeAction for Request #{request.id}: {e}", + exc_info=True, + ) + + def _apply_action_effects(action): """Применяет эффекты кадрового события (как в EmployeeActionViewSet).""" try: diff --git a/backend/requests_app/enums.py b/backend/requests_app/enums.py index 650b2cef..5ead9e5f 100644 --- a/backend/requests_app/enums.py +++ b/backend/requests_app/enums.py @@ -9,6 +9,7 @@ class RequestType(TextChoices): VACATION = "vacation", "Отпуск" SICK_LEAVE = "sick_leave", "Больничный" DAY_OFF = "day_off", "Отгул" + MATERNITY = "maternity", "Декрет" TRANSFER = "transfer", "Перевод" DISMISSAL = "dismissal", "Увольнение" OTHER = "other", "Другое" diff --git a/backend/requests_app/management/commands/migrate_requests_to_actions.py b/backend/requests_app/management/commands/migrate_requests_to_actions.py index 78065dd6..97592734 100644 --- a/backend/requests_app/management/commands/migrate_requests_to_actions.py +++ b/backend/requests_app/management/commands/migrate_requests_to_actions.py @@ -5,7 +5,7 @@ Management команда для миграции существующих за python manage.py migrate_requests_to_actions [--dry-run] """ -from datetime import datetime, timedelta +from datetime import datetime from django.core.management.base import BaseCommand from django.utils import timezone @@ -19,14 +19,6 @@ from employees.services.request_actions import ( ) -# Маппинг для событий возврата из отпуска/больничного -RETURN_ACTION_MAPPING = { - "vacation": "returned_from_leave", - "sick_leave": "returned_from_sick_leave", - "day_off": "returned_from_day_off", -} - - class Command(BaseCommand): help = ( "Создает EmployeeAction для всех одобренных заявок, " @@ -67,7 +59,6 @@ class Command(BaseCommand): created_count = 0 skipped_count = 0 - return_count = 0 # Счётчик событий возврата errors = [] for request in approved_requests: @@ -87,23 +78,8 @@ class Command(BaseCommand): ) ) - # Проверяем нужно ли создать событие возврата - should_create_return = False - return_action = None - return_date = None - - if request.type in RETURN_ACTION_MAPPING and request.date_to: - return_action = RETURN_ACTION_MAPPING.get(request.type) - return_date = request.date_to + timedelta(days=1) - - if return_action: - existing_return = get_existing_request_action( - request, return_action - ) - should_create_return = existing_return is None - # Если нечего создавать - пропускаем - if not should_create_main and not should_create_return: + if not should_create_main: continue # Определяем дату события и нормализуем к datetime с timezone @@ -148,15 +124,6 @@ class Command(BaseCommand): ) created_count += 1 - # Создаём событие возврата - if should_create_return: - self.stdout.write( - self.style.SUCCESS( - f" WOULD CREATE RETURN: Request #{request.id} → " - f"EmployeeAction ({return_action}) on {return_date}" - ) - ) - return_count += 1 else: try: # Создаём основное событие @@ -167,6 +134,9 @@ class Command(BaseCommand): action_date=request.date_from or request.decided_at or request.created_at, + date_to=request.date_to + if request.type in SCHEDULED_ACTION_MAPPING + else None, comment=build_request_action_comment(request), extra=extra_data, ) @@ -184,38 +154,6 @@ class Command(BaseCommand): else: skipped_count += 1 - # Создаём событие возврата - if should_create_return and return_action and return_date: - return_comment = ( - f"Автоматически: окончание " - f"{request.get_type_display().lower()} " - f"(заявка #{request.id})" - ) - return_action_obj, created = create_request_action( - request=request, - action_type=return_action, - action_date=return_date, - comment=return_comment, - extra={ - "auto_return": True, - "migrated": True, - "migration_date": timezone.now().isoformat(), - }, - ) - if created: - self._apply_effects(return_action_obj) - return_count += 1 - self.stdout.write( - self.style.SUCCESS( - " ✓ Created RETURN: EmployeeAction " - f"#{return_action_obj.id}" - " " - f"({return_action}) on {return_date}" - ) - ) - else: - skipped_count += 1 - except Exception as e: errors.append((request.id, str(e))) self.stdout.write( @@ -241,12 +179,6 @@ class Command(BaseCommand): self.style.SUCCESS(f"{verb} событий: {created_count}") ) - if return_count: - verb = "Будет создано" if dry_run else "Создано" - self.stdout.write( - self.style.SUCCESS(f"{verb} событий возврата: {return_count}") - ) - if skipped_count: self.stdout.write( self.style.WARNING( diff --git a/backend/requests_app/migrations/0012_add_maternity_request_type.py b/backend/requests_app/migrations/0012_add_maternity_request_type.py new file mode 100644 index 00000000..6ca1eccf --- /dev/null +++ b/backend/requests_app/migrations/0012_add_maternity_request_type.py @@ -0,0 +1,32 @@ +# Generated by Codex on 2026-04-28 + +from django.db import migrations, models + + +REQUEST_TYPE_CHOICES = [ + ("vacation", "Отпуск"), + ("sick_leave", "Больничный"), + ("day_off", "Отгул"), + ("maternity", "Декрет"), + ("transfer", "Перевод"), + ("dismissal", "Увольнение"), + ("other", "Другое"), +] + + +class Migration(migrations.Migration): + dependencies = [ + ("requests_app", "0011_remove_requestcomment_model"), + ] + + operations = [ + migrations.AlterField( + model_name="request", + name="type", + field=models.CharField( + choices=REQUEST_TYPE_CHOICES, + max_length=32, + verbose_name="Тип заявления", + ), + ), + ] diff --git a/backend/requests_app/models.py b/backend/requests_app/models.py index 4ea61dd0..2edffac7 100644 --- a/backend/requests_app/models.py +++ b/backend/requests_app/models.py @@ -201,6 +201,7 @@ class Request(models.Model): RequestType.VACATION, RequestType.SICK_LEAVE, RequestType.DAY_OFF, + RequestType.MATERNITY, } and not (self.date_from and self.date_to): raise ValidationError( _("Для выбранного типа укажите даты начала и окончания.") diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py index efbdabd8..11f6fa69 100644 --- a/backend/requests_app/tasks.py +++ b/backend/requests_app/tasks.py @@ -3,7 +3,6 @@ Celery tasks для автоматизации кадровых событий """ import logging -from datetime import date, timedelta from celery import shared_task from django.utils import timezone @@ -26,6 +25,7 @@ def create_scheduled_action(self, request_id): from employees.constants import ( ACTION_ON_DAY_OFF, ACTION_ON_LEAVE, + ACTION_ON_MATERNITY, ACTION_ON_SICK_LEAVE, ) from employees.services.request_actions import ( @@ -49,6 +49,7 @@ def create_scheduled_action(self, request_id): "vacation": ACTION_ON_LEAVE, "sick_leave": ACTION_ON_SICK_LEAVE, "day_off": ACTION_ON_DAY_OFF, + "maternity": ACTION_ON_MATERNITY, } action_type = action_mapping.get(request.type) @@ -63,6 +64,7 @@ def create_scheduled_action(self, request_id): request=request, action_type=action_type, action_date=request.date_from, + date_to=request.date_to, comment=build_request_action_comment(request), extra={ "approved_by": (request.approver.id if request.approver else None), @@ -78,18 +80,6 @@ def create_scheduled_action(self, request_id): f"for Request #{request_id}" ) - # Планируем автовозврат на date_to + 1 день - if request.date_to: - schedule_auto_return.apply_async( - args=[request_id], - eta=timezone.make_aware( - timezone.datetime.combine( - request.date_to + timedelta(days=1), - timezone.datetime.min.time(), - ) - ), - ) - return f"Created action {action.id}" except Request.DoesNotExist: @@ -107,80 +97,29 @@ def create_scheduled_action(self, request_id): @shared_task(bind=True, max_retries=3) def schedule_auto_return(self, request_id): """ - Автоматически создать событие возврата из отпуска/больничного/отгула. + Legacy no-op для старых очередей Celery. - Вызывается в date_to + 1 день. - - Args: - request_id: ID заявки + Возвраты больше не закрывают временные кадровые состояния: период + хранится в EmployeeAction.date_to. Старые запланированные задачи должны + спокойно завершаться и не создавать дублирующих событий. """ from .models import Request - from .enums import RequestStatus - from employees.constants import ( - ACTION_RETURNED_FROM_DAY_OFF, - ACTION_RETURNED_FROM_LEAVE, - ACTION_RETURNED_FROM_SICK_LEAVE, - ) - from employees.services.request_actions import create_request_action try: request = Request.objects.get(id=request_id) - - # Проверяем, что заявка все еще одобрена - if request.status != RequestStatus.APPROVED: - logger.info( - f"Request #{request_id} is no longer approved, skipping auto-return" - ) - return - - # Определяем тип возврата - return_mapping = { - "vacation": ACTION_RETURNED_FROM_LEAVE, - "sick_leave": ACTION_RETURNED_FROM_SICK_LEAVE, - "day_off": ACTION_RETURNED_FROM_DAY_OFF, - } - - return_action = return_mapping.get(request.type) - if not return_action: - logger.warning( - f"Request #{request_id} type '{request.type}' " - f"doesn't have return action" - ) - return - - return_comment = ( - f"Автоматически: окончание " - f"{request.get_type_display().lower()} " - f"(заявка #{request.id})" - ) - return_date = ( - request.date_to + timedelta(days=1) if request.date_to else None - ) - - action, created = create_request_action( - request=request, - action_type=return_action, - action_date=return_date, - comment=return_comment, - extra={"auto_return": True}, - ) - if not created: - logger.info(f"Return action already exists for Request #{request_id}") - return - logger.info( - f"Created auto-return EmployeeAction #{action.id} " - f"({return_action}) for Request #{request_id}" + "Skipping legacy auto-return for Request #%s; interval ends at %s", + request_id, + request.date_to, ) - - return f"Created return action {action.id}" + return "Auto-return disabled" except Request.DoesNotExist: logger.error(f"Request #{request_id} not found for auto-return") raise except Exception as e: logger.error( - f"Failed to create auto-return for Request #{request_id}: {e}", + f"Failed to process legacy auto-return for Request #{request_id}: {e}", exc_info=True, ) raise self.retry(exc=e, countdown=300) @@ -189,60 +128,7 @@ def schedule_auto_return(self, request_id): @shared_task def cleanup_missed_returns(): """ - Periodic task: проверяет заявки, для которых date_to прошел, - но событие возврата не создано. - - Запускается ежедневно в 00:05. + Legacy no-op: возвратные события больше не создаются автоматически. """ - from .models import Request - from .enums import RequestStatus - from employees.services.request_actions import create_request_action - - logger.info("Running cleanup_missed_returns task") - - today = date.today() - yesterday = today - timedelta(days=1) - - # Ищем одобренные заявки, которые закончились вчера или раньше - ended_requests = Request.objects.filter( - status=RequestStatus.APPROVED, - type__in=["vacation", "sick_leave", "day_off"], - date_to__lt=today, - date_to__gte=yesterday - timedelta(days=7), # За последнюю неделю - ) - - created_count = 0 - - for req in ended_requests: - # Определяем тип возврата - return_action = { - "vacation": "returned_from_leave", - "sick_leave": "returned_from_sick_leave", - "day_off": "returned_from_day_off", - }.get(req.type) - - if not return_action: - continue - - try: - action, created = create_request_action( - request=req, - action_type=return_action, - action_date=req.date_to + timedelta(days=1), - comment=( - f"Автоматически восстановлено: окончание " - f"{req.get_type_display().lower()} (заявка #{req.id})" - ), - extra={"auto_return": True, "cleanup": True}, - ) - if not created: - continue - created_count += 1 - logger.info( - f"Cleanup: created return action #{action.id} for Request #{req.id}" - ) - except Exception as e: - logger.error(f"Cleanup: failed to create return for Request #{req.id}: {e}") - - logger.info(f"cleanup_missed_returns completed: {created_count} actions created") - return created_count + logger.info("cleanup_missed_returns skipped: auto-return is disabled") + return 0 diff --git a/backend/requests_app/tests/test_signals.py b/backend/requests_app/tests/test_signals.py index 10a100f5..0f2d3c74 100644 --- a/backend/requests_app/tests/test_signals.py +++ b/backend/requests_app/tests/test_signals.py @@ -76,19 +76,20 @@ class TestRequestSignals: assert action.action == "transferred" assert action.extra["immediate"] is True - @patch("employees.signals.common._schedule_delayed_action") - def test_scheduled_action_vacation(self, mock_schedule, user_factory): - """Отпуск запускает планирование через Celery.""" + @patch("requests_app.tasks.schedule_auto_return.apply_async") + def test_scheduled_action_vacation(self, mock_auto_return, user_factory): + """Отпуск создаёт интервальное EmployeeAction сразу при одобрении.""" employee = user_factory() approver = user_factory() future_date = date.today() + timedelta(days=7) + end_date = future_date + timedelta(days=10) request = Request.objects.create( employee=employee, type="vacation", status=RequestStatus.PENDING, date_from=future_date, - date_to=future_date + timedelta(days=10), + date_to=end_date, ) # Одобряем @@ -96,30 +97,69 @@ class TestRequestSignals: request.approver = approver request.save() - # Проверяем что вызвалась функция планирования - mock_schedule.assert_called_once() - called_request = mock_schedule.call_args[0][0] - assert called_request.id == request.id + action = EmployeeAction.objects.get( + employee=employee, + action="on_leave", + source_request=request, + ) + assert action.date.date() == future_date + assert action.date_to == end_date + assert action.extra["interval"] is True + mock_auto_return.assert_not_called() - @patch("employees.signals.common._schedule_delayed_action") - def test_scheduled_action_sick_leave(self, mock_schedule, user_factory): - """Больничный запускает планирование через Celery.""" + def test_scheduled_action_sick_leave(self, user_factory): + """Больничный создаёт интервальное EmployeeAction.""" employee = user_factory() approver = user_factory() future_date = date.today() + timedelta(days=3) + end_date = future_date + timedelta(days=2) request = Request.objects.create( employee=employee, type="sick_leave", status=RequestStatus.PENDING, date_from=future_date, + date_to=end_date, ) request.status = RequestStatus.APPROVED request.approver = approver request.save() - mock_schedule.assert_called_once() + action = EmployeeAction.objects.get( + employee=employee, + action="on_sick_leave", + source_request=request, + ) + assert action.date.date() == future_date + assert action.date_to == end_date + + def test_scheduled_action_maternity(self, user_factory): + """Декрет создаёт интервальное EmployeeAction.""" + employee = user_factory() + approver = user_factory() + start_date = date.today() + timedelta(days=14) + end_date = start_date + timedelta(days=140) + + request = Request.objects.create( + employee=employee, + type="maternity", + status=RequestStatus.PENDING, + date_from=start_date, + date_to=end_date, + ) + + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + action = EmployeeAction.objects.get( + employee=employee, + action="on_maternity", + source_request=request, + ) + assert action.date.date() == start_date + assert action.date_to == end_date def test_no_duplicate_actions(self, user_factory): """Повторное сохранение одобренной заявки не создаёт дубликаты.""" @@ -280,6 +320,7 @@ class TestRequestMappings: "vacation": "on_leave", "sick_leave": "on_sick_leave", "day_off": "on_day_off", + "maternity": "on_maternity", } def test_no_overlap_in_mappings(self): diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index f61367d5..2cf6e0f5 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -18,6 +18,7 @@ from common.logstorm_client import LogStormClientError from employees.constants import ( ACTION_DISMISSED, ACTION_ON_LEAVE, + ACTION_REMOTE, ACTION_RETURNED_FROM_LEAVE, ) from employees.models import EmployeeAction @@ -1355,6 +1356,7 @@ def test_personnel_non_working_state_is_saved_and_suppresses_absence( employee=employee, action=ACTION_ON_LEAVE, date=_aware_datetime(2026, 4, 20), + date_to=datetime(2026, 4, 25).date(), ) with patch( @@ -1465,6 +1467,151 @@ def test_calendar_non_working_day_suppresses_absence_without_personnel_state( assert matrix_cell["issues"] == [] +def test_remote_personnel_state_is_saved_and_suppresses_attendance_issues( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + remote_action = EmployeeAction.objects.create( + employee=employee, + action=ACTION_REMOTE, + date=_aware_datetime(2026, 4, 1), + ) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + + late_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + assert late_record.personnel_status == ACTION_REMOTE + assert late_record.personnel_status_label == "На удалёнке" + assert late_record.personnel_action == remote_action + assert late_record.effective_is_workday is True + assert late_record.is_late is False + assert late_record.is_underwork is False + assert late_record.employee_issues == [] + assert late_record.statuses == [] + + absent_record = AttendanceRecord.objects.get(employee=employee, date="2026-04-21") + assert absent_record.personnel_status == ACTION_REMOTE + assert absent_record.effective_is_workday is True + assert absent_record.is_absent is False + assert absent_record.employee_issues == [] + assert absent_record.statuses == [] + + matrix_response = client.get( + _monthly_matrix_url(), + { + "employee_ids": str(employee.id), + "month": "2026-04", + }, + ) + matrix_row = next( + row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-21" + ) + matrix_cell = matrix_row["cells"][str(employee.id)] + assert matrix_cell["status"] == "normal" + assert matrix_cell["short_label"] == "УД" + assert matrix_cell["display_text"] == "УД" + assert matrix_cell["primary_label"] == "Удаленка" + assert matrix_cell["issues"] == [] + + +def test_remote_personnel_state_does_not_replace_calendar_weekend( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + _move_auto_hired_before_test_period(employee) + client = auth_client_factory(staff) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_REMOTE, + date=_aware_datetime(2026, 4, 1), + ) + weekend_result = { + "records": [ + { + "date": "2026-04-19", + "employee_id": str(employee.id), + "display_name": "Ivan Petrov", + "arrival_time": None, + "departure_time": None, + "work_hours": 0, + "expected_hours": 0, + "is_workday": False, + "is_late": False, + "late_minutes": None, + "is_early_leave": False, + "early_leave_minutes": None, + "is_underwork": False, + "underwork_hours": None, + "is_overtime": False, + "overtime_hours": None, + "is_absent": True, + "statuses": ["absence"], + "employee_issues": ["absence"], + "technical_issues": [], + } + ] + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=weekend_result, + ): + response = client.post( + _analyze_url(), + _payload(employee.id, period_start="2026-04-19", period_end="2026-04-19"), + format="json", + ) + + assert response.status_code == 200 + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-19") + assert record.personnel_status == ACTION_REMOTE + assert record.personnel_status_label + assert record.effective_is_workday is False + assert record.raw_data["non_working_reason"] == "Выходной по графику/календарю" + + records_response = client.get( + _records_url(), + { + "employee_id": employee.id, + "date_from": "2026-04-19", + "date_to": "2026-04-19", + }, + ) + assert records_response.status_code == 200 + assert records_response.json()["results"][0]["non_working_reason"] == ( + "Выходной по графику/календарю" + ) + + matrix_response = client.get( + _monthly_matrix_url(), + { + "employee_ids": str(employee.id), + "month": "2026-04", + }, + ) + matrix_row = next( + row for row in matrix_response.json()["rows"] if row["date"] == "2026-04-19" + ) + matrix_cell = matrix_row["cells"][str(employee.id)] + assert matrix_cell["status"] == "non_working" + assert matrix_cell["short_label"] == "Вых" + assert matrix_cell["display_text"] == "Вых" + assert matrix_cell["primary_label"] == "Выходной по графику/календарю" + assert matrix_cell["non_working_reason"] == "Выходной по графику/календарю" + + def test_personnel_non_working_state_counts_work_as_overtime( auth_client_factory, user_factory, @@ -1477,6 +1624,7 @@ def test_personnel_non_working_state_counts_work_as_overtime( employee=employee, action=ACTION_ON_LEAVE, date=_aware_datetime(2026, 4, 20), + date_to=datetime(2026, 4, 25).date(), ) with patch( @@ -1549,10 +1697,11 @@ def test_personnel_state_is_recalculated_when_actions_change( employee = user_factory() _move_auto_hired_before_test_period(employee) client = auth_client_factory(staff) - EmployeeAction.objects.create( + leave_action = EmployeeAction.objects.create( employee=employee, action=ACTION_ON_LEAVE, date=_aware_datetime(2026, 4, 20), + date_to=datetime(2026, 4, 25).date(), ) with patch( @@ -1565,6 +1714,8 @@ def test_personnel_state_is_recalculated_when_actions_change( assert first_record.personnel_status == ACTION_ON_LEAVE assert first_record.is_absent is False + leave_action.date_to = datetime(2026, 4, 20).date() + leave_action.save(update_fields=["date_to"]) EmployeeAction.objects.create( employee=employee, action=ACTION_RETURNED_FROM_LEAVE, diff --git a/backend/tests/api/v1/employees/test_employee_actions.py b/backend/tests/api/v1/employees/test_employee_actions.py index ca1c9d03..0894f3bf 100644 --- a/backend/tests/api/v1/employees/test_employee_actions.py +++ b/backend/tests/api/v1/employees/test_employee_actions.py @@ -1,4 +1,6 @@ import pytest +from datetime import timedelta +from django.db import connection from django.urls import reverse from django.utils import timezone from django.contrib.auth.models import Permission @@ -6,7 +8,14 @@ from rest_framework import status from unittest.mock import patch, Mock from employees.models import Employee, Department, EmployeeDepartment, EmployeeAction -from employees.constants import ACTION_DISMISSED, ACTION_CHOICES, ACTION_HIRED +from employees.constants import ( + ACTION_CHOICES, + ACTION_DISMISSED, + ACTION_HIRED, + ACTION_ON_LEAVE, + ACTION_REHIRED, + ACTION_WORKING, +) # helpers _seq = 1 @@ -144,6 +153,95 @@ def test_create_requires_perm_or_staff(api_client): assert EmployeeAction.objects.filter(employee=target).count() == baseline2 + 1 +@pytest.mark.django_db +def test_temporary_action_accepts_date_to(api_client): + staff = _user(staff=True) + target = _user() + api_client.force_authenticate(user=staff) + url = reverse("api:v1:employee-actions-list") + + resp = api_client.post( + url, + { + "employee": target.id, + "action": ACTION_ON_LEAVE, + "date": "2026-04-20T12:00:00Z", + "date_to": "2026-04-25", + }, + format="json", + ) + + assert resp.status_code == 201 + action = EmployeeAction.objects.get(id=resp.data["id"]) + assert action.date_to.isoformat() == "2026-04-25" + assert resp.data["date_to"] == "2026-04-25" + + +@pytest.mark.django_db +def test_temporary_action_requires_date_to(api_client): + staff = _user(staff=True) + target = _user() + api_client.force_authenticate(user=staff) + url = reverse("api:v1:employee-actions-list") + + resp = api_client.post( + url, + { + "employee": target.id, + "action": ACTION_ON_LEAVE, + "date": "2026-04-20T12:00:00Z", + }, + format="json", + ) + + assert resp.status_code == 400 + assert "date_to" in resp.data + + +@pytest.mark.django_db +def test_permanent_action_rejects_date_to(api_client): + staff = _user(staff=True) + target = _user() + api_client.force_authenticate(user=staff) + url = reverse("api:v1:employee-actions-list") + + resp = api_client.post( + url, + { + "employee": target.id, + "action": ACTION_HIRED, + "date": "2026-04-20T12:00:00Z", + "date_to": "2026-04-25", + }, + format="json", + ) + + assert resp.status_code == 400 + assert "date_to" in resp.data + + +@pytest.mark.django_db +def test_temporary_action_rejects_date_to_before_start(api_client): + staff = _user(staff=True) + target = _user() + api_client.force_authenticate(user=staff) + url = reverse("api:v1:employee-actions-list") + + resp = api_client.post( + url, + { + "employee": target.id, + "action": ACTION_ON_LEAVE, + "date": "2026-04-20T12:00:00Z", + "date_to": "2026-04-19", + }, + format="json", + ) + + assert resp.status_code == 400 + assert "date_to" in resp.data + + @pytest.mark.django_db def test_dismissal_deactivates_employee_and_links(api_client): staff = _user(staff=True) @@ -171,7 +269,8 @@ def test_dismissal_deactivates_employee_and_links(api_client): @pytest.mark.django_db -def test_non_dismissal_activates_employee(api_client): +@pytest.mark.parametrize("action", [ACTION_WORKING, ACTION_HIRED, ACTION_REHIRED]) +def test_working_and_activation_markers_activate_employee(api_client, action): staff = _user(staff=True) api_client.force_authenticate(user=staff) emp = _user() @@ -183,7 +282,7 @@ def test_non_dismissal_activates_employee(api_client): url, { "employee": emp.id, - "action": _any_non_dismissed(), + "action": action, "date": timezone.now().isoformat(), }, format="json", @@ -193,6 +292,30 @@ def test_non_dismissal_activates_employee(api_client): assert emp.is_active is True +@pytest.mark.django_db +def test_temporary_action_does_not_activate_employee(api_client): + staff = _user(staff=True) + api_client.force_authenticate(user=staff) + emp = _user() + emp.is_active = False + emp.save(update_fields=["is_active"]) + + url = reverse("api:v1:employee-actions-list") + resp = api_client.post( + url, + { + "employee": emp.id, + "action": ACTION_ON_LEAVE, + "date": "2026-04-20T12:00:00Z", + "date_to": "2026-04-25", + }, + format="json", + ) + assert resp.status_code == 201 + emp.refresh_from_db() + assert emp.is_active is False + + @pytest.mark.django_db def test_update_to_dismissed_applies_effects(api_client): actor = _user() @@ -212,6 +335,35 @@ def test_update_to_dismissed_applies_effects(api_client): assert emp.is_active is False +@pytest.mark.django_db +def test_update_historical_dismissal_uses_current_state(api_client): + actor = _user() + api_client.force_authenticate(user=actor) + _grant(actor, "employees.change_employeeaction") + emp = _user(is_active=True) + old_dismissal = EmployeeAction.objects.create( + employee=emp, + action=ACTION_DISMISSED, + date=timezone.now() - timedelta(days=30), + ) + EmployeeAction.objects.create( + employee=emp, + action=ACTION_WORKING, + date=timezone.now() - timedelta(days=10), + ) + + url_det = reverse("api:v1:employee-actions-detail", args=[old_dismissal.id]) + resp = api_client.patch( + url_det, + {"comment": "Уточнили старое событие"}, + format="json", + ) + + assert resp.status_code == 200 + emp.refresh_from_db() + assert emp.is_active is True + + @pytest.mark.django_db def test_delete_requires_perm_or_staff(api_client): actor = _user() @@ -230,6 +382,67 @@ def test_delete_requires_perm_or_staff(api_client): assert api_client.delete(url_det).status_code == 204 +@pytest.mark.django_db +def test_delete_current_dismissal_recalculates_active_state(api_client): + actor = _user() + api_client.force_authenticate(user=actor) + _grant(actor, "employees.delete_employeeaction") + emp = _user(is_active=False) + EmployeeAction.objects.create( + employee=emp, + action=ACTION_WORKING, + date=timezone.now() - timedelta(days=10), + ) + dismissal = EmployeeAction.objects.create( + employee=emp, + action=ACTION_DISMISSED, + date=timezone.now() - timedelta(days=1), + ) + + url_det = reverse("api:v1:employee-actions-detail", args=[dismissal.id]) + assert api_client.delete(url_det).status_code == 204 + + emp.refresh_from_db() + assert emp.is_active is True + + +@pytest.mark.django_db(transaction=True) +def test_delete_clears_legacy_state_table_references(api_client): + actor = _user() + api_client.force_authenticate(user=actor) + _grant(actor, "employees.delete_employeeaction") + emp = _user() + act = EmployeeAction.objects.create( + employee=emp, action=_any_non_dismissed(), date=timezone.now() + ) + + table = "employees_employeebasestatusevent" + with connection.cursor() as cursor: + cursor.execute(f'DROP TABLE IF EXISTS "{table}"') + cursor.execute( + f""" + CREATE TABLE "{table}" ( + "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, + "source_action_id" bigint NULL UNIQUE + REFERENCES "employees_employeeaction" ("id") + DEFERRABLE INITIALLY DEFERRED + ) + """ + ) + cursor.execute( + f'INSERT INTO "{table}" ("source_action_id") VALUES (%s)', + [act.id], + ) + + try: + url_det = reverse("api:v1:employee-actions-detail", args=[act.id]) + assert api_client.delete(url_det).status_code == 204 + assert not EmployeeAction.objects.filter(id=act.id).exists() + finally: + with connection.cursor() as cursor: + cursor.execute(f'DROP TABLE IF EXISTS "{table}"') + + @pytest.mark.django_db @patch('employees.signals.ldap.employee._is_ldap_enabled', return_value=True) @patch('employees.signals.ldap.employee.UserService.update_user') @@ -361,7 +574,7 @@ def test_non_dismissal_syncs_activation_to_ldap( url, { "employee": emp.id, - "action": _any_non_dismissed(), + "action": ACTION_HIRED, "date": timezone.now().isoformat(), }, format="json", @@ -537,7 +750,7 @@ def test_restoration_moves_from_dismissed_to_users( url, { "employee": emp.id, - "action": _any_non_dismissed(), + "action": ACTION_REHIRED, "date": timezone.now().isoformat(), }, format="json", diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py index 7563bbe2..e09f2c35 100644 --- a/backend/tests/api/v1/requests_app/test_requests_api.py +++ b/backend/tests/api/v1/requests_app/test_requests_api.py @@ -64,6 +64,14 @@ def test_statistics_counts_all_employee_requests_for_admin( type_=RequestType.DAY_OFF, status=RequestStatus.PENDING, ) + maternity = make_request( + employee=employee, + type_=RequestType.MATERNITY, + status=RequestStatus.APPROVED, + ) + maternity.date_from = datetime(2026, 4, 1).date() + maternity.date_to = datetime(2026, 4, 10).date() + maternity.save(update_fields=["date_from", "date_to"]) resp = auth_client(admin_user).get( f"{API_BASE}statistics/?employee_id={employee.id}&period=all" @@ -75,9 +83,11 @@ def test_statistics_counts_all_employee_requests_for_admin( assert payload["period"] == "all" assert payload["date_from"] is None assert payload["date_to"] is None - assert payload["total_submitted_requests"] == 2 + assert payload["total_submitted_requests"] == 3 assert payload["sick_leave_requests_count"] == 1 assert payload["day_off_requests_count"] == 1 + assert payload["maternity_requests_count"] == 1 + assert payload["maternity_days"] == 10 def test_statistics_custom_period_filters_request_counts_and_absence_days( @@ -301,6 +311,56 @@ def test_create_regular_user_and_admin_forced_to_self( ) +def test_maternity_request_requires_and_accepts_period( + auth_client, regular_user: models.Model, make_user +) -> None: + recipient = make_user(email="maternity-recipient@example.com") + + missing_date_resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.MATERNITY, + "date_from": "2026-04-20", + "recipient_ids": [recipient.id], + }, + format="json", + ) + assert missing_date_resp.status_code == 400 + assert "date_to" in missing_date_resp.json() + + ok_resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.MATERNITY, + "date_from": "2026-04-20", + "date_to": "2026-09-06", + "recipient_ids": [recipient.id], + }, + format="json", + ) + assert ok_resp.status_code == 201 + assert ok_resp.json()["type"] == RequestType.MATERNITY + + +def test_day_off_request_requires_full_period( + auth_client, regular_user: models.Model, make_user +) -> None: + recipient = make_user(email="day-off-recipient@example.com") + + resp = auth_client(regular_user).post( + API_BASE, + data={ + "type": RequestType.DAY_OFF, + "date_from": "2026-04-20", + "recipient_ids": [recipient.id], + }, + format="json", + ) + + assert resp.status_code == 400 + assert "date_to" in resp.json() + + def test_create_request_sends_notification_to_cc_users( auth_client, regular_user: models.Model, make_user ) -> None: diff --git a/backend/tests/employees/test_personnel_state.py b/backend/tests/employees/test_personnel_state.py new file mode 100644 index 00000000..c9b6abbe --- /dev/null +++ b/backend/tests/employees/test_personnel_state.py @@ -0,0 +1,204 @@ +from datetime import date + +import pytest +from django.utils import timezone + +from employees.constants import ( + ACTION_DISMISSED, + ACTION_HIRED, + ACTION_ON_DAY_OFF, + ACTION_ON_LEAVE, + ACTION_ON_SICK_LEAVE, + ACTION_REHIRED, + ACTION_REMOTE, + ACTION_RETURNED_FROM_LEAVE, + ACTION_WORKING, +) +from employees.models import EmployeeAction +from employees.services.personnel_state import ( + PERSONNEL_STATUS_NORMAL, + resolve_employee_personnel_state, +) + +pytestmark = pytest.mark.django_db + + +def _aware_datetime(year: int, month: int, day: int): + return timezone.make_aware(timezone.datetime(year, month, day, 12)) + + +def test_permanent_working_state_expects_attendance(user_factory): + employee = user_factory() + working = EmployeeAction.objects.create( + employee=employee, + action=ACTION_WORKING, + date=_aware_datetime(2026, 4, 10), + ) + + state = resolve_employee_personnel_state(employee, date(2026, 4, 20)) + + assert state.status == ACTION_WORKING + assert state.action_id == working.id + assert state.expects_attendance is True + + +def test_no_actions_falls_back_to_normal_state(user_factory): + employee = user_factory() + EmployeeAction.objects.filter(employee=employee).delete() + + state = resolve_employee_personnel_state(employee, date(2026, 4, 20)) + + assert state.status == PERSONNEL_STATUS_NORMAL + assert state.expects_attendance is True + + +def test_temporary_interval_is_active_until_date_to(user_factory): + employee = user_factory() + leave = EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + date_to=date(2026, 4, 22), + ) + + state = resolve_employee_personnel_state(employee, date(2026, 4, 21)) + + assert state.status == ACTION_ON_LEAVE + assert state.action_id == leave.id + assert state.date_to == date(2026, 4, 22) + assert state.expects_attendance is False + + +def test_temporary_interval_ends_after_date_to(user_factory): + employee = user_factory() + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + date_to=date(2026, 4, 22), + ) + + state = resolve_employee_personnel_state(employee, date(2026, 4, 23)) + + assert state.status == ACTION_WORKING + assert state.expects_attendance is True + + +def test_temporary_without_date_to_is_one_day_legacy_fallback(user_factory): + employee = user_factory() + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + ) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_RETURNED_FROM_LEAVE, + date=_aware_datetime(2026, 4, 23), + ) + + active = resolve_employee_personnel_state(employee, date(2026, 4, 20)) + ended = resolve_employee_personnel_state(employee, date(2026, 4, 21)) + returned = resolve_employee_personnel_state(employee, date(2026, 4, 23)) + + assert active.status == ACTION_ON_LEAVE + assert active.date_to == date(2026, 4, 20) + assert ended.status == ACTION_WORKING + assert returned.status == ACTION_RETURNED_FROM_LEAVE + assert returned.expects_attendance is True + + +def test_marker_action_is_one_day_status(user_factory): + employee = user_factory() + hired = EmployeeAction.objects.create( + employee=employee, + action=ACTION_HIRED, + date=_aware_datetime(2026, 4, 20), + ) + + marker = resolve_employee_personnel_state(employee, date(2026, 4, 20)) + fallback = resolve_employee_personnel_state(employee, date(2026, 4, 21)) + + assert marker.status == ACTION_HIRED + assert marker.action_id == hired.id + assert marker.expects_attendance is True + assert fallback.status == ACTION_WORKING + + +def test_rehire_restores_previous_permanent_state_after_dismissal(user_factory): + employee = user_factory() + remote = EmployeeAction.objects.create( + employee=employee, + action=ACTION_REMOTE, + date=_aware_datetime(2026, 4, 10), + ) + dismissed = EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=_aware_datetime(2026, 4, 12), + ) + rehire = EmployeeAction.objects.create( + employee=employee, + action=ACTION_REHIRED, + date=_aware_datetime(2026, 4, 15), + ) + + dismissed_state = resolve_employee_personnel_state(employee, date(2026, 4, 13)) + rehire_state = resolve_employee_personnel_state(employee, date(2026, 4, 15)) + restored_state = resolve_employee_personnel_state(employee, date(2026, 4, 16)) + + assert dismissed_state.status == ACTION_DISMISSED + assert dismissed_state.action_id == dismissed.id + assert rehire_state.status == ACTION_REHIRED + assert rehire_state.action_id == rehire.id + assert rehire_state.expects_attendance is True + assert restored_state.status == ACTION_REMOTE + assert restored_state.action_id == remote.id + + +def test_overlapping_temporary_intervals_use_priority(user_factory): + employee = user_factory() + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + date_to=date(2026, 4, 25), + ) + sick = EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_SICK_LEAVE, + date=_aware_datetime(2026, 4, 21), + date_to=date(2026, 4, 22), + ) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_DAY_OFF, + date=_aware_datetime(2026, 4, 21), + date_to=date(2026, 4, 21), + ) + + state = resolve_employee_personnel_state(employee, date(2026, 4, 21)) + + assert state.status == ACTION_ON_SICK_LEAVE + assert state.action_id == sick.id + + +def test_dismissal_overrides_temporary_interval(user_factory): + employee = user_factory() + EmployeeAction.objects.create( + employee=employee, + action=ACTION_ON_LEAVE, + date=_aware_datetime(2026, 4, 20), + date_to=date(2026, 4, 25), + ) + dismissed = EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=_aware_datetime(2026, 4, 21), + ) + + state = resolve_employee_personnel_state(employee, date(2026, 4, 22)) + + assert state.status == ACTION_DISMISSED + assert state.action_id == dismissed.id + assert state.expects_attendance is False diff --git a/backend/tests/requests_app/test_request_actions.py b/backend/tests/requests_app/test_request_actions.py index a33799d1..1775b81d 100644 --- a/backend/tests/requests_app/test_request_actions.py +++ b/backend/tests/requests_app/test_request_actions.py @@ -43,6 +43,7 @@ def test_scheduled_task_is_idempotent(user_factory): employee=employee, approver=approver, date_from=start_date, + date_to=start_date + timedelta(days=5), ) create_scheduled_action.apply(args=[request.id]) @@ -58,9 +59,34 @@ def test_scheduled_task_is_idempotent(user_factory): assert action.extra["request_id"] == request.id assert action.extra["scheduled"] is True assert action.date.date() == start_date + assert action.date_to == start_date + timedelta(days=5) -def test_auto_return_task_is_idempotent(user_factory): +def test_scheduled_task_supports_maternity(user_factory): + employee = user_factory() + approver = user_factory() + start_date = date(2026, 4, 20) + end_date = date(2026, 9, 6) + request = _approved_request( + employee=employee, + approver=approver, + type_="maternity", + date_from=start_date, + date_to=end_date, + ) + + create_scheduled_action.apply(args=[request.id]) + + action = EmployeeAction.objects.get( + source_request=request, + action="on_maternity", + ) + assert action.date.date() == start_date + assert action.date_to == end_date + assert action.extra["scheduled"] is True + + +def test_auto_return_task_is_legacy_noop(user_factory): employee = user_factory() approver = user_factory() end_date = date(2026, 4, 25) @@ -71,19 +97,15 @@ def test_auto_return_task_is_idempotent(user_factory): date_to=end_date, ) - schedule_auto_return.apply(args=[request.id]) - schedule_auto_return.apply(args=[request.id]) + first = schedule_auto_return.apply(args=[request.id]) + second = schedule_auto_return.apply(args=[request.id]) - actions = EmployeeAction.objects.filter( + assert first.result == "Auto-return disabled" + assert second.result == "Auto-return disabled" + assert not EmployeeAction.objects.filter( source_request=request, action="returned_from_leave", - ) - assert actions.count() == 1 - - action = actions.get() - assert action.extra["request_id"] == request.id - assert action.extra["auto_return"] is True - assert action.date.date() == end_date + timedelta(days=1) + ).exists() def test_immediate_approval_is_idempotent(user_factory): @@ -223,13 +245,23 @@ def test_migration_0049_backfills_source_request_and_removes_duplicates(): extra={"request_id": request.id}, ) - migrate_to([("employees", "0049_employeeaction_source_request")]) + apps_0049 = migrate_to( + [("employees", "0049_employeeaction_source_request")] + ) + EmployeeAction0049 = apps_0049.get_model("employees", "EmployeeAction") - actions = EmployeeAction.objects.filter( + actions = EmployeeAction0049.objects.filter( source_request_id=request.id, action="on_leave", ) assert actions.count() == 1 assert actions.get().id == first.id + + migrate_to_latest() + action = EmployeeAction.objects.get( + source_request_id=request.id, + action="on_leave", + ) + assert action.date_to == date(2026, 4, 25) finally: migrate_to_latest() diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index 17952c56..23c73fc2 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -28,15 +28,20 @@ import { import { Modal } from "@/components/ui"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; +import { buildQuery } from "@/lib/api/utils"; import { loadAllPages, displayUserName, formatDateTime } from "@/lib/shared"; import { resolveMediaUrl } from "@/lib/url"; import type { + AttendanceAnalysisResponse, MonthlyAttendanceMatrix, MonthlyAttendanceMatrixCell, MonthlyAttendanceMatrixEmployee, MonthlyAttendanceMatrixRow, AttendanceRecord, AttendanceAutoSyncSettings, + AttendanceMatrixExportFile, + AttendanceSchedulePayload, + StandardWorkSchedule, } from "@/lib/api/attendance"; import type { User } from "@/types/api"; @@ -56,6 +61,31 @@ const matrixEmployeeColumnWidth = 160; const matrixModalHorizontalPadding = 96; type AttendancePeriodPreset = "month" | "custom"; +type EmployeeQuery = { + search?: string; + department?: string; + page?: number; + limit?: number; + is_active?: boolean; + ordering?: string; +}; +type PaginatedEmployees = { results?: User[]; next?: string | null } | User[]; +type AutoSyncSettingsPatch = Partial>; +type AttendanceMatrixExportQuery = { + employee_ids: string; + period_start: string; + period_end: string; +}; +type AttendanceAnalyzeRequest = { + employee_id: number; + period_start: string; + period_end: string; + schedule?: AttendanceSchedulePayload; +}; +type MonthlyMatrixRequest = { employee_ids: string; month: string }; const periodPresets: Array<{ value: AttendancePeriodPreset; label: string }> = [ { value: "month", label: "За месяц" }, @@ -149,6 +179,201 @@ function getErrorMessage(error: unknown, fallback: string) { return String((error as Error)?.message || fallback); } +function getEmployees(params?: EmployeeQuery): Promise { + const client = apiClient as typeof apiClient & { + getEmployees?: (params?: EmployeeQuery) => Promise; + }; + + if (typeof client.getEmployees === "function") { + return client.getEmployees(params); + } + + const searchParams = new URLSearchParams(); + if (params?.search) searchParams.set("search", params.search); + if (params?.department) searchParams.set("department", params.department); + if (params?.page) searchParams.set("page", String(params.page)); + if (params?.limit) searchParams.set("limit", String(params.limit)); + if (params?.is_active !== undefined) { + searchParams.set("active", String(params.is_active)); + } + if (params?.ordering) searchParams.set("ordering", params.ordering); + const query = searchParams.toString(); + return apiClient.request( + `/api/v1/employees/${query ? `?${query}` : ""}`, + ); +} + +function getStandardWorkSchedule() { + const client = apiClient as typeof apiClient & { + getStandardWorkSchedule?: () => Promise; + }; + + if (typeof client.getStandardWorkSchedule === "function") { + return client.getStandardWorkSchedule(); + } + + return apiClient.request( + "/api/v1/attendance/standard-work-schedule/", + ); +} + +function updateStandardWorkSchedule(data: Partial) { + const client = apiClient as typeof apiClient & { + updateStandardWorkSchedule?: ( + data: Partial, + ) => Promise; + }; + + if (typeof client.updateStandardWorkSchedule === "function") { + return client.updateStandardWorkSchedule(data); + } + + return apiClient.request( + "/api/v1/attendance/standard-work-schedule/", + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); +} + +function getAttendanceAutoSyncSettings() { + const client = apiClient as typeof apiClient & { + getAttendanceAutoSyncSettings?: () => Promise; + }; + + if (typeof client.getAttendanceAutoSyncSettings === "function") { + return client.getAttendanceAutoSyncSettings(); + } + + return apiClient.request( + "/api/v1/attendance/auto-sync-settings/", + ); +} + +function updateAttendanceAutoSyncSettings( + data: AutoSyncSettingsPatch, +) { + const client = apiClient as typeof apiClient & { + updateAttendanceAutoSyncSettings?: ( + data: AutoSyncSettingsPatch, + ) => Promise; + }; + + if (typeof client.updateAttendanceAutoSyncSettings === "function") { + return client.updateAttendanceAutoSyncSettings(data); + } + + return apiClient.request( + "/api/v1/attendance/auto-sync-settings/", + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); +} + +function runAttendanceAutoSyncNow() { + const client = apiClient as typeof apiClient & { + runAttendanceAutoSyncNow?: () => Promise; + }; + + if (typeof client.runAttendanceAutoSyncNow === "function") { + return client.runAttendanceAutoSyncNow(); + } + + return apiClient.request( + "/api/v1/attendance/auto-sync/run-now/", + { method: "POST" }, + ); +} + +async function downloadMonthlyAttendanceMatrix( + params: AttendanceMatrixExportQuery, +): Promise { + const client = apiClient as typeof apiClient & { + downloadMonthlyAttendanceMatrix?: ( + params: AttendanceMatrixExportQuery, + ) => Promise; + getToken?: () => string | null; + }; + + if (typeof client.downloadMonthlyAttendanceMatrix === "function") { + return client.downloadMonthlyAttendanceMatrix(params); + } + + const headers: Record = {}; + const token = typeof client.getToken === "function" ? client.getToken() : null; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch( + `/api/v1/attendance/monthly-matrix/export/${buildQuery(params)}`, + { method: "GET", headers }, + ); + if (!response.ok) { + let detail = response.statusText; + try { + const payload = await response.json(); + detail = payload.detail || JSON.stringify(payload); + } catch { + detail = response.statusText; + } + throw new Error(`API Error: ${response.status} ${detail}`); + } + const contentDisposition = response.headers.get("content-disposition"); + const filename = contentDisposition?.match(/filename="?([^";]+)"?/i)?.[1] + || `attendance-${params.period_start}_${params.period_end}.xlsx`; + return { + blob: await response.blob(), + filename, + }; +} + +function analyzeAttendance(data: AttendanceAnalyzeRequest) { + const client = apiClient as typeof apiClient & { + analyzeAttendance?: (data: AttendanceAnalyzeRequest) => Promise; + }; + + if (typeof client.analyzeAttendance === "function") { + return client.analyzeAttendance(data); + } + + return apiClient.request( + "/api/v1/attendance/logstorm/analyze/", + { + method: "POST", + body: JSON.stringify(data), + }, + ); +} + +function getMonthlyAttendanceMatrix(params: MonthlyMatrixRequest) { + const client = apiClient as typeof apiClient & { + getMonthlyAttendanceMatrix?: ( + params: MonthlyMatrixRequest, + ) => Promise; + }; + + if (typeof client.getMonthlyAttendanceMatrix === "function") { + return client.getMonthlyAttendanceMatrix(params); + } + + return apiClient.request( + `/api/v1/attendance/monthly-matrix/${buildQuery(params)}`, + ); +} + +function getAttendanceRecord(recordId: number) { + const client = apiClient as typeof apiClient & { + getAttendanceRecord?: (recordId: number) => Promise; + }; + + if (typeof client.getAttendanceRecord === "function") { + return client.getAttendanceRecord(recordId); + } + + return apiClient.request(`/api/v1/attendance/records/${recordId}/`); +} + function formatOptionalDateTime(value?: string | null) { return value ? formatDateTime(value) : "-"; } @@ -406,7 +631,7 @@ export default function AttendancePage() { useState(null); const [dayEventsRecordPreview, setDayEventsRecordPreview] = useState(null); - const [openedRecordFromUrl, setOpenedRecordFromUrl] = useState(false); + const [openedRecordFromUrl, setOpenedRecordFromUrl] = useState(null); const [statsModalOpen, setStatsModalOpen] = useState(false); const [actionsMenuOpen, setActionsMenuOpen] = useState(false); const actionsMenuRef = useRef(null); @@ -425,7 +650,7 @@ export default function AttendancePage() { setLoadingEmployees(true); setError(null); const nextEmployees = await loadAllPages((params) => - apiClient.getEmployees({ + getEmployees({ ...params, is_active: showInactiveEmployees ? undefined : true, ordering: "last_name", @@ -492,7 +717,7 @@ export default function AttendancePage() { try { setLoadingSchedule(true); - const schedule = await apiClient.getStandardWorkSchedule(); + const schedule = await getStandardWorkSchedule(); if (cancelled) return; setScheduleStart(toTimeInputValue(schedule.start_time) || "08:00"); setScheduleEnd(toTimeInputValue(schedule.end_time) || "17:00"); @@ -529,7 +754,7 @@ export default function AttendancePage() { try { setLoadingAutoSync(true); - const settings = await apiClient.getAttendanceAutoSyncSettings(); + const settings = await getAttendanceAutoSyncSettings(); if (!cancelled) setAutoSyncSettings(settings); } catch (autoSyncError) { if (!cancelled) { @@ -560,7 +785,6 @@ export default function AttendancePage() { }, []); useEffect(() => { - if (openedRecordFromUrl) return; const params = new URLSearchParams(window.location.search); const shouldOpenEvents = params.get("events") === "1"; const shouldOpenComments = params.get("comments") === "1"; @@ -568,13 +792,16 @@ export default function AttendancePage() { const recordId = Number(params.get("record")); if (!Number.isInteger(recordId) || recordId <= 0) return; + const mode = shouldOpenComments ? "comments" : "events"; + const openKey = `${recordId}:${mode}`; + if (openedRecordFromUrl === openKey) return; + let cancelled = false; - setOpenedRecordFromUrl(true); async function openRecordFromUrl() { try { setError(null); - const record = await apiClient.getAttendanceRecord(recordId); + const record = await getAttendanceRecord(recordId); if (cancelled) return; const preview = { recordId, @@ -583,19 +810,17 @@ export default function AttendancePage() { statusLabel: attendanceRecordStatusLabel(record), displayText: attendanceRecordDisplay(record), detailLines: attendanceRecordDetails(record), - }; - if (shouldOpenComments) { - setCommentsRecordPreview({ - ...preview, - commentsCount: Number(record.comments_count || 0), - }); - return; - } - setDayEventsRecordPreview({ - ...preview, issues: attendanceRecordIssueLabels(record), isManuallyEdited: Boolean(record.is_manually_edited), - }); + commentsCount: Number(record.comments_count || 0), + }; + if (shouldOpenComments) { + setCommentsRecordPreview(preview); + setOpenedRecordFromUrl(openKey); + return; + } + setDayEventsRecordPreview(preview); + setOpenedRecordFromUrl(openKey); } catch (loadError) { if (!cancelled) { setError(getErrorMessage(loadError, "Не удалось открыть запись посещаемости")); @@ -608,7 +833,7 @@ export default function AttendancePage() { return () => { cancelled = true; }; - }, [openedRecordFromUrl]); + }, [openedRecordFromUrl, user?.id]); const filteredEmployees = useMemo(() => { const query = employeeSearch.trim().toLowerCase(); @@ -732,7 +957,7 @@ export default function AttendancePage() { setSavingSchedule(true); setError(null); setScheduleFeedback(null); - const schedule = await apiClient.updateStandardWorkSchedule({ + const schedule = await updateStandardWorkSchedule({ start_time: scheduleStart, end_time: scheduleEnd, expected_hours: hours, @@ -761,7 +986,7 @@ export default function AttendancePage() { setSavingAutoSync(true); setError(null); setAutoSyncFeedback(null); - const settings = await apiClient.updateAttendanceAutoSyncSettings(patch); + const settings = await updateAttendanceAutoSyncSettings(patch); setAutoSyncSettings(settings); setAutoSyncFeedback("Автообновление сохранено"); } catch (saveError) { @@ -776,7 +1001,7 @@ export default function AttendancePage() { setRunningAutoSync(true); setError(null); setAutoSyncFeedback(null); - const settings = await apiClient.runAttendanceAutoSyncNow(); + const settings = await runAttendanceAutoSyncNow(); setAutoSyncSettings(settings); setAutoSyncFeedback("Автообновление выполнено"); } catch (runError) { @@ -824,7 +1049,7 @@ export default function AttendancePage() { try { setDownloadingReport(true); const employeeIds = selectedEmployees.map((employee) => employee.id).join(","); - const file = await apiClient.downloadMonthlyAttendanceMatrix({ + const file = await downloadMonthlyAttendanceMatrix({ employee_ids: employeeIds, period_start: periodStart, period_end: periodEnd, @@ -857,7 +1082,7 @@ export default function AttendancePage() { for (const employee of selectedEmployees) { if (options?.refreshFromAnalyzer) { - await apiClient.analyzeAttendance({ + await analyzeAttendance({ employee_id: employee.id, period_start: periodStart, period_end: periodEnd, @@ -868,7 +1093,7 @@ export default function AttendancePage() { const monthKeys = getMonthKeysBetween(periodStart, periodEnd); const nextMatrices = await Promise.all( monthKeys.map((month) => - apiClient.getMonthlyAttendanceMatrix({ + getMonthlyAttendanceMatrix({ employee_ids: employeeIds, month, }), @@ -943,6 +1168,8 @@ export default function AttendancePage() { statusLabel: cell.primary_label || matrixStatusLabel(cell.status), displayText: matrixCellDisplay(cell) || "Нет записи", detailLines: cell.detail_lines || [], + issues: matrixCellIssueLabels(cell).map(formatAttendanceIssueLabel), + isManuallyEdited: Boolean(cell.is_manually_edited), commentsCount: cell.comments_count, }); } @@ -967,6 +1194,35 @@ export default function AttendancePage() { detailLines: cell.detail_lines || [], issues: matrixCellIssueLabels(cell).map(formatAttendanceIssueLabel), isManuallyEdited: Boolean(cell.is_manually_edited), + commentsCount: cell.comments_count, + }); + } + + function openDayEventsFromComments(record: AttendanceRecordCommentsPreview) { + setDayEventsRecordPreview({ + recordId: record.recordId, + employeeName: record.employeeName, + date: record.date, + statusLabel: record.statusLabel, + displayText: record.displayText, + detailLines: record.detailLines, + issues: record.issues, + isManuallyEdited: record.isManuallyEdited, + commentsCount: record.commentsCount, + }); + } + + function openCommentsFromDayEvents(record: AttendanceDayEventsPreview) { + setCommentsRecordPreview({ + recordId: record.recordId, + employeeName: record.employeeName, + date: record.date, + statusLabel: record.statusLabel, + displayText: record.displayText, + detailLines: record.detailLines, + issues: record.issues, + isManuallyEdited: record.isManuallyEdited, + commentsCount: record.commentsCount, }); } @@ -977,11 +1233,13 @@ export default function AttendancePage() { isOpen={Boolean(commentsRecordPreview)} onClose={() => setCommentsRecordPreview(null)} onCommentCountChange={updateSelectedCellCommentCount} + onOpenDayEvents={openDayEventsFromComments} record={commentsRecordPreview} /> setDayEventsRecordPreview(null)} + onOpenComments={openCommentsFromDayEvents} record={dayEventsRecordPreview} /> diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index f5a6b1c4..57d7ad7c 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -116,11 +116,11 @@ export default function ProfilePage() { - {currentAction.action_display || currentAction.action} + {user.personnel_state.label || user.personnel_state.status} ) : null } @@ -181,7 +181,8 @@ export default function ProfilePage() { actionLoading={null} canManageActions={false} canViewActions - latestActionId={currentAction?.id ?? null} + latestActionId={user.personnel_state?.action_id ?? currentAction?.id ?? null} + personnelState={user.personnel_state} sortedActions={sortedActions} initialVisibleCount={3} showCountLabel={false} @@ -191,8 +192,8 @@ export default function ProfilePage() { diff --git a/frontend/src/app/users/[id]/page.tsx b/frontend/src/app/users/[id]/page.tsx index 067d0654..6f7c143e 100644 --- a/frontend/src/app/users/[id]/page.tsx +++ b/frontend/src/app/users/[id]/page.tsx @@ -353,16 +353,16 @@ export default function UserDetailPage() { - {latestAction ? ( + {person.personnel_state ? ( - {latestAction.action_display || latestAction.action} + {person.personnel_state.label || person.personnel_state.status} ) : null} {(canEdit || canManageActions) ? ( @@ -517,10 +517,11 @@ export default function UserDetailPage() { actionLoading={actionLoading} canManageActions={canManageActions} canViewActions={canViewActions} - latestActionId={latestAction?.id ?? null} + latestActionId={person.personnel_state?.action_id ?? latestAction?.id ?? null} onAddAction={handleOpenActionModal} onDeleteAction={handleDeleteAction} onEditAction={handleEditAction} + personnelState={person.personnel_state} sortedActions={sortedActions} truncateCommentLength={120} expandedCommentLength={240} @@ -528,8 +529,8 @@ export default function UserDetailPage() { diff --git a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx index f8bad446..1efa3f99 100644 --- a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx +++ b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx @@ -1,8 +1,9 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Camera, Clock3, ImageOff, Loader2 } from "lucide-react"; +import { Camera, Clock3, ImageOff, Loader2, MessageSquare } from "lucide-react"; +import { AttendanceRecordHeader } from "@/components/attendance/AttendanceRecordHeader"; import { Modal } from "@/components/ui/Modal"; import { apiClient } from "@/lib/api"; import type { AttendanceDayEvent } from "@/lib/api/attendance"; @@ -16,11 +17,13 @@ export type AttendanceDayEventsPreview = { detailLines?: string[]; issues?: string[]; isManuallyEdited?: boolean; + commentsCount?: number; }; type AttendanceDayEventsModalProps = { isOpen: boolean; onClose: () => void; + onOpenComments?: (record: AttendanceDayEventsPreview) => void; record: AttendanceDayEventsPreview | null; }; @@ -28,6 +31,47 @@ function getErrorMessage(error: unknown, fallback: string) { return String((error as Error)?.message || fallback); } +function getAttendanceRecordDayEvents(recordId: number) { + const client = apiClient as typeof apiClient & { + getAttendanceRecordDayEvents?: (recordId: number) => Promise; + }; + + if (typeof client.getAttendanceRecordDayEvents === "function") { + return client.getAttendanceRecordDayEvents(recordId); + } + + return apiClient.request( + `/api/v1/attendance/records/${recordId}/day-events/`, + ); +} + +async function getAttendanceDayEventPhoto(photoUrl: string) { + const client = apiClient as typeof apiClient & { + getAttendanceDayEventPhoto?: (photoUrl: string) => Promise; + getToken?: () => string | null; + }; + + if (typeof client.getAttendanceDayEventPhoto === "function") { + return client.getAttendanceDayEventPhoto(photoUrl); + } + + const headers: Record = {}; + const token = typeof client.getToken === "function" ? client.getToken() : null; + if (token) headers.Authorization = `Bearer ${token}`; + const response = await fetch(photoUrl, { method: "GET", headers }); + if (!response.ok) { + let detail = response.statusText; + try { + const payload = await response.json(); + detail = payload.detail || payload.error || JSON.stringify(payload); + } catch { + detail = response.statusText; + } + throw new Error(`API Error: ${response.status} ${detail}`); + } + return response.blob(); +} + function revokePhotoUrls(urls: Record) { Object.values(urls).forEach((url) => URL.revokeObjectURL(url)); } @@ -35,6 +79,7 @@ function revokePhotoUrls(urls: Record) { export function AttendanceDayEventsModal({ isOpen, onClose, + onOpenComments, record, }: AttendanceDayEventsModalProps) { const [events, setEvents] = useState([]); @@ -62,7 +107,7 @@ export function AttendanceDayEventsModal({ setLoadingPhotos(false); try { - const nextEvents = await apiClient.getAttendanceRecordDayEvents(recordId); + const nextEvents = await getAttendanceRecordDayEvents(recordId); if (cancelled) return; setEvents(nextEvents); @@ -74,7 +119,7 @@ export function AttendanceDayEventsModal({ setLoadingPhotos(true); const loadedEntries = await Promise.all( eventsWithPhotos.map(async (event) => { - const blob = await apiClient.getAttendanceDayEventPhoto(event.photo_url as string); + const blob = await getAttendanceDayEventPhoto(event.photo_url as string); return [event.event_key, URL.createObjectURL(blob)] as const; }), ); @@ -114,6 +159,12 @@ export function AttendanceDayEventsModal({ onClose(); }; + function openComments() { + if (!record || !onOpenComments) return; + close(); + onOpenComments(record); + } + return ( {record ? (
-
-

Запись посещения

-
-
-

- {record.employeeName} -

-

- {record.date} · {record.displayText || "Нет записи"} -

-
- - {record.statusLabel} - -
- {record.detailLines?.length ? ( -
- {record.detailLines.slice(0, 8).map((line) => ( -

{line}

- ))} -
+ + + Комментарии{record.commentsCount !== undefined ? ` (${record.commentsCount})` : ""} + ) : null} - {record.issues?.length ? ( -
- {record.issues.map((issue) => ( - - {issue} - - ))} -
- ) : null} - {record.isManuallyEdited ? ( -
- Ручная корректировка EUSRR -
- ) : null} -
+ record={record} + />
-

События LogStorm за день

+

Подробности события за день

{loadingPhotos ? ( @@ -190,7 +217,7 @@ export function AttendanceDayEventsModal({ Событий за день нет

- LogStorm не вернул проходы для этой даты. + Проходы за эту дату не найдены.

) : ( diff --git a/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx b/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx index b7ebe1df..62642aa8 100644 --- a/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx +++ b/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx @@ -1,8 +1,9 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Loader2, MessageSquare, Send } from "lucide-react"; +import { Camera, Loader2, MessageSquare, Send } from "lucide-react"; +import { AttendanceRecordHeader } from "@/components/attendance/AttendanceRecordHeader"; import { CommentDeleteButton } from "@/components/shared/CommentControls"; import { RequestAvatar } from "@/components/requests/RequestAvatar"; import { Modal } from "@/components/ui/Modal"; @@ -17,6 +18,8 @@ export type AttendanceRecordCommentsPreview = { statusLabel: string; displayText: string; detailLines?: string[]; + issues?: string[]; + isManuallyEdited?: boolean; commentsCount?: number; }; @@ -25,6 +28,7 @@ type AttendanceRecordCommentsModalProps = { isOpen: boolean; onClose: () => void; onCommentCountChange?: (recordId: number, count: number) => void; + onOpenDayEvents?: (record: AttendanceRecordCommentsPreview) => void; record: AttendanceRecordCommentsPreview | null; }; @@ -32,6 +36,62 @@ function getErrorMessage(error: unknown, fallback: string) { return String((error as Error)?.message || fallback); } +function getAttendanceRecordComments(recordId: number) { + const client = apiClient as typeof apiClient & { + getAttendanceRecordComments?: (recordId: number) => Promise; + request?: (endpoint: string, options?: RequestInit) => Promise; + }; + + if (typeof client.getAttendanceRecordComments === "function") { + return client.getAttendanceRecordComments(recordId); + } + + return client.request?.( + `/api/v1/attendance/records/${recordId}/comments/`, + ) ?? Promise.reject(new Error("Attendance comments API is unavailable")); +} + +function addAttendanceRecordComment(recordId: number, text: string) { + const client = apiClient as typeof apiClient & { + addAttendanceRecordComment?: ( + recordId: number, + text: string, + ) => Promise; + request?: (endpoint: string, options?: RequestInit) => Promise; + }; + + if (typeof client.addAttendanceRecordComment === "function") { + return client.addAttendanceRecordComment(recordId, text); + } + + return client.request?.( + `/api/v1/attendance/records/${recordId}/comments/`, + { + method: "POST", + body: JSON.stringify({ text }), + }, + ) ?? Promise.reject(new Error("Attendance comments API is unavailable")); +} + +function deleteAttendanceRecordComment(recordId: number, commentId: number) { + const client = apiClient as typeof apiClient & { + deleteAttendanceRecordComment?: ( + recordId: number, + commentId: number, + ) => Promise; + request?: (endpoint: string, options?: RequestInit) => Promise; + }; + + if (typeof client.deleteAttendanceRecordComment === "function") { + return client.deleteAttendanceRecordComment(recordId, commentId); + } + + return client.request?.( + `/api/v1/attendance/records/${recordId}/comments/${commentId}/`, + { method: "DELETE" }, + ) ?? Promise.reject(new Error("Attendance comments API is unavailable")); +} + function commentAuthorName(comment: AttendanceRecordComment) { return displayUserName(comment.author) || comment.author?.email || "Сотрудник"; } @@ -50,9 +110,11 @@ export function AttendanceRecordCommentsModal({ isOpen, onClose, onCommentCountChange, + onOpenDayEvents, record, }: AttendanceRecordCommentsModalProps) { const [comments, setComments] = useState([]); + const [commentsLoaded, setCommentsLoaded] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [draft, setDraft] = useState(""); @@ -73,6 +135,7 @@ export function AttendanceRecordCommentsModal({ setDraft(""); setError(null); setActionId(null); + setCommentsLoaded(false); }, []); const scrollToBottom = useCallback((smooth = true) => { @@ -87,9 +150,11 @@ export function AttendanceRecordCommentsModal({ const loadComments = useCallback(async (recordId: number) => { try { setLoading(true); + setCommentsLoaded(false); setError(null); - const nextComments = await apiClient.getAttendanceRecordComments(recordId); + const nextComments = await getAttendanceRecordComments(recordId); setComments(nextComments); + setCommentsLoaded(true); onCommentCountChangeRef.current?.(recordId, nextComments.length); } catch (loadError) { setComments([]); @@ -133,9 +198,10 @@ export function AttendanceRecordCommentsModal({ try { setSending(true); setError(null); - const saved = await apiClient.addAttendanceRecordComment(record.recordId, text); + const saved = await addAttendanceRecordComment(record.recordId, text); const nextComments = [...comments, saved]; setComments(nextComments); + setCommentsLoaded(true); setDraft(""); onCommentCountChange?.(record.recordId, nextComments.length); scrollToBottom(); @@ -151,9 +217,10 @@ export function AttendanceRecordCommentsModal({ if (!record) return; try { setActionId(comment.id); - await apiClient.deleteAttendanceRecordComment(record.recordId, comment.id); + await deleteAttendanceRecordComment(record.recordId, comment.id); const nextComments = comments.filter((item) => item.id !== comment.id); setComments(nextComments); + setCommentsLoaded(true); onCommentCountChange?.(record.recordId, nextComments.length); } catch (deleteError) { setError(getErrorMessage(deleteError, "Не удалось удалить комментарий")); @@ -164,6 +231,19 @@ export function AttendanceRecordCommentsModal({ [comments, onCommentCountChange, record], ); + const displayedCommentsCount = commentsLoaded + ? comments.length + : record?.commentsCount ?? comments.length; + + function openDayEvents() { + if (!record || !onOpenDayEvents) return; + onClose(); + onOpenDayEvents({ + ...record, + commentsCount: displayedCommentsCount, + }); + } + return (
-
-

Запись посещения

-
-
-
-

- {record.employeeName} -

-

- {record.date} · {record.displayText || "Нет записи"} -

-
- - {record.statusLabel} - -
- {record.detailLines?.length ? ( -
- {record.detailLines.slice(0, 6).map((line) => ( -

{line}

- ))} -
- ) : null} -
- Комментариев: {Math.max(comments.length, record.commentsCount || 0)} -
-
-
+ + + Подробности события + + ) : null} + record={{ + ...record, + commentsCount: displayedCommentsCount, + }} + /> {error ? (
diff --git a/frontend/src/components/attendance/AttendanceRecordHeader.tsx b/frontend/src/components/attendance/AttendanceRecordHeader.tsx new file mode 100644 index 00000000..7c468a8a --- /dev/null +++ b/frontend/src/components/attendance/AttendanceRecordHeader.tsx @@ -0,0 +1,83 @@ +"use client"; + +import type { ReactNode } from "react"; + +export type AttendanceRecordHeaderData = { + employeeName: string; + date: string; + statusLabel: string; + displayText: string; + detailLines?: string[]; + issues?: string[]; + isManuallyEdited?: boolean; + commentsCount?: number; +}; + +type AttendanceRecordHeaderProps = { + actions?: ReactNode; + record: AttendanceRecordHeaderData; +}; + +function visibleDetailLines(lines?: string[]) { + return (lines || []).filter( + (line) => !String(line).trim().toLowerCase().startsWith("комментари"), + ); +} + +export function AttendanceRecordHeader({ + actions, + record, +}: AttendanceRecordHeaderProps) { + const details = visibleDetailLines(record.detailLines); + + return ( +
+
+
+

Запись посещения

+

+ {record.employeeName} +

+

+ {record.date} · {record.displayText || "Нет записи"} +

+
+ + {record.statusLabel} + +
+ + {details.length ? ( +
+ {details.slice(0, 8).map((line) => ( +

{line}

+ ))} +
+ ) : null} + +
+ {record.commentsCount !== undefined ? ( + + Комментариев: {record.commentsCount} + + ) : null} + {record.issues?.map((issue) => ( + + {issue} + + ))} + {record.isManuallyEdited ? ( + + Ручная корректировка EUSRR + + ) : null} +
+ + {actions ? ( +
+ {actions} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx index 78e3dd46..a0beb2ce 100644 --- a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx +++ b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { AlertTriangle, + Camera, ChevronDown, Clock, Edit3, @@ -13,6 +14,10 @@ import { RefreshCw, } from "lucide-react"; +import { + AttendanceDayEventsModal, + type AttendanceDayEventsPreview, +} from "@/components/attendance/AttendanceDayEventsModal"; import { CommentComposer, CommentDeleteButton } from "@/components/shared/CommentControls"; import { Modal } from "@/components/ui"; import { useUser } from "@/contexts/UserContext"; @@ -23,6 +28,7 @@ import type { AttendanceAnalysisResponse, AttendanceRecord, AttendanceRecordUpdatePayload, + PaginatedAttendanceRecords, } from "@/lib/api/attendance"; import type { EmployeeAction } from "@/types/api"; @@ -178,15 +184,20 @@ function getPersonnelDayMeta( record?: AttendanceRecord, ): PersonnelDayMeta | null { if (record?.personnel_status && record.personnel_status !== "normal") { + const personnelStatus = String(record.personnel_status); return { action: { id: Number(record.personnel_action || 0), employee: 0, - action: record.personnel_status, + action: personnelStatus, action_display: record.personnel_status_label || record.personnel_status, date: String(dateValue || ""), }, - className: record.personnel_status === "dismissed" ? "text-red-400" : "text-amber-500", + className: personnelStatus === "dismissed" + ? "text-red-400" + : personnelStatus === "remote" + ? "text-sky-400" + : "text-amber-500", label: record.personnel_status_label || record.personnel_status, nonWorking: record.effective_is_workday === false, }; @@ -251,9 +262,26 @@ function getPersonnelDayMeta( }; } + if (activeAction.action === "remote") { + return { + action: activeAction, + className: "text-sky-400", + label: activeAction.action_display || "На удалёнке", + nonWorking: false, + }; + } + return null; } +function isRemotePersonnelMeta(personnelMeta?: PersonnelDayMeta | null) { + return personnelMeta?.action.action === "remote"; +} + +function isRemoteNonWorkingReason(value?: string) { + return String(value || "").toLowerCase().replaceAll("ё", "е").includes("удален"); +} + function issueLabels(record: AttendanceRecord, personnelMeta?: PersonnelDayMeta | null) { const labels = [ ...(record.statuses || []), @@ -270,7 +298,7 @@ function issueLabels(record: AttendanceRecord, personnelMeta?: PersonnelDayMeta : labels; const result = [...effectiveLabels]; - if (!nonWorking) { + if (!nonWorking && !isRemotePersonnelMeta(personnelMeta)) { if (record.is_absent) result.push("absence"); if (record.is_late) result.push("late"); if (record.is_early_leave) result.push("early_leave"); @@ -309,6 +337,14 @@ function getRecordTone(record: AttendanceRecord, personnelMeta?: PersonnelDayMet }; } + if (isRemotePersonnelMeta(personnelMeta)) { + return { + dotClassName: "bg-sky-500", + pillClassName: "app-selected", + label: personnelMeta?.label || "На удалёнке", + }; + } + return { dotClassName: "bg-emerald-500", pillClassName: "app-feedback-success", @@ -317,6 +353,17 @@ function getRecordTone(record: AttendanceRecord, personnelMeta?: PersonnelDayMet } function getWorkdayMeta(record: AttendanceRecord, personnelMeta?: PersonnelDayMeta | null) { + if (personnelMeta?.nonWorking && isRemotePersonnelMeta(personnelMeta) && !record.is_workday) { + const reason = !isRemoteNonWorkingReason(record.non_working_reason) + ? record.non_working_reason + : ""; + return { + className: "text-gray-500", + label: "Нерабочий день", + reason: reason || "Выходной по графику/календарю", + }; + } + if (personnelMeta?.nonWorking) { return { className: personnelMeta.className, @@ -333,10 +380,14 @@ function getWorkdayMeta(record: AttendanceRecord, personnelMeta?: PersonnelDayMe }; } + const nonWorkingReason = isRemotePersonnelMeta(personnelMeta) + ? "" + : record.non_working_reason; + return { className: "text-gray-500", label: "Нерабочий день", - reason: record.non_working_reason || "Выходной по графику/календарю", + reason: nonWorkingReason || "Выходной по графику/календарю", }; } @@ -361,6 +412,17 @@ function getRecordId(record: AttendanceRecord) { return Number.isFinite(value) && value > 0 ? value : null; } +function attendanceRecordDetails(record: AttendanceRecord, workdayMeta: ReturnType) { + return [ + `Приход: ${formatTime(record.arrival_time)}`, + `Уход: ${formatTime(record.departure_time)}`, + `Часы: ${formatHours(record.work_hours)} / ${formatHours(record.expected_hours)}`, + `Рабочий день: ${record.effective_is_workday === false || record.is_workday === false ? "нет" : "да"}`, + ...(workdayMeta.reason ? [`Причина: ${workdayMeta.reason}`] : []), + ...(record.comments_count ? [`Комментарии: ${record.comments_count}`] : []), + ]; +} + function valueToEditString(value: unknown) { if (value === null || value === undefined) return ""; return String(value); @@ -401,6 +463,111 @@ function nullableTime(value: string) { return trimmed || null; } +type AttendanceCardApiClient = typeof apiClient & { + request: (endpoint: string, options?: RequestInit) => Promise; + addAttendanceRecordComment?: unknown; + analyzeAttendance?: unknown; + deleteAttendanceRecordComment?: unknown; + getAttendanceRecordComments?: unknown; + getAttendanceRecords?: unknown; + updateAttendanceRecord?: unknown; +}; + +function buildAttendanceQuery(params?: Record) { + if (!params) return ""; + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== "") query.append(key, String(value)); + }); + const serialized = query.toString(); + return serialized ? `?${serialized}` : ""; +} + +function attendanceApiClient() { + return apiClient as AttendanceCardApiClient; +} + +function analyzeEmployeeAttendance(data: { + employee_id: number; + period_start: string; + period_end: string; +}) { + const client = attendanceApiClient(); + if (typeof client.analyzeAttendance === "function") { + return client.analyzeAttendance(data) as Promise; + } + return client.request( + "/api/v1/attendance/logstorm/analyze/", + { + method: "POST", + body: JSON.stringify(data), + }, + ); +} + +function getAttendanceRecords(params: { + employee_id: number; + date_from: string; + date_to: string; +}) { + const client = attendanceApiClient(); + if (typeof client.getAttendanceRecords === "function") { + return client.getAttendanceRecords(params) as Promise; + } + return client.request( + `/api/v1/attendance/records/${buildAttendanceQuery(params)}`, + ); +} + +function getAttendanceRecordComments(recordId: number) { + const client = attendanceApiClient(); + if (typeof client.getAttendanceRecordComments === "function") { + return client.getAttendanceRecordComments(recordId) as Promise; + } + return client.request( + `/api/v1/attendance/records/${recordId}/comments/`, + ); +} + +function addAttendanceRecordComment(recordId: number, text: string) { + const client = attendanceApiClient(); + if (typeof client.addAttendanceRecordComment === "function") { + return client.addAttendanceRecordComment(recordId, text) as Promise; + } + return client.request( + `/api/v1/attendance/records/${recordId}/comments/`, + { + method: "POST", + body: JSON.stringify({ text }), + }, + ); +} + +function deleteAttendanceRecordComment(recordId: number, commentId: number) { + const client = attendanceApiClient(); + if (typeof client.deleteAttendanceRecordComment === "function") { + return client.deleteAttendanceRecordComment(recordId, commentId) as Promise; + } + return client.request( + `/api/v1/attendance/records/${recordId}/comments/${commentId}/`, + { method: "DELETE" }, + ); +} + +function updateAttendanceRecord(recordId: number, data: AttendanceRecordUpdatePayload) { + const client = attendanceApiClient(); + if (typeof client.updateAttendanceRecord === "function") { + return client.updateAttendanceRecord(recordId, data) as Promise; + } + return client.request( + `/api/v1/attendance/records/${recordId}/`, + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); +} + export default function EmployeeAttendanceCard({ employeeActions, employeeId, @@ -420,6 +587,8 @@ export default function EmployeeAttendanceCard({ const [commentsMap, setCommentsMap] = useState>({}); const [commentsLoadingMap, setCommentsLoadingMap] = useState>({}); const [commentDrafts, setCommentDrafts] = useState>({}); + const [dayEventsRecordPreview, setDayEventsRecordPreview] = + useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [editForm, setEditForm] = useState(null); @@ -464,12 +633,12 @@ export default function EmployeeAttendanceCard({ try { setLoading(true); setLoadingMode("analyze"); - await apiClient.analyzeAttendance({ + await analyzeEmployeeAttendance({ employee_id: employeeId, period_start: periodStart, period_end: periodEnd, }); - const savedRecords = await apiClient.getAttendanceRecords({ + const savedRecords = await getAttendanceRecords({ employee_id: employeeId, date_from: periodStart, date_to: periodEnd, @@ -513,7 +682,7 @@ export default function EmployeeAttendanceCard({ setLoading(true); setLoadingMode("saved"); setError(null); - const response = await apiClient.getAttendanceRecords({ + const response = await getAttendanceRecords({ employee_id: employeeId, date_from: periodStart, date_to: periodEnd, @@ -566,7 +735,7 @@ export default function EmployeeAttendanceCard({ try { setCommentsLoadingMap((current) => ({ ...current, [recordId]: true })); - const comments = await apiClient.getAttendanceRecordComments(recordId); + const comments = await getAttendanceRecordComments(recordId); setCommentsMap((current) => ({ ...current, [recordId]: comments })); updateRecordCommentCount(recordId, comments.length); } catch (commentError) { @@ -585,13 +754,38 @@ export default function EmployeeAttendanceCard({ } } + async function openCommentsFromDayEvents(record: AttendanceDayEventsPreview) { + setExpandedComments((current) => ({ ...current, [record.recordId]: true })); + await ensureCommentsLoaded(record.recordId); + } + + function openDayEvents(record: AttendanceRecord, recordId: number, key: string) { + const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record); + const tone = getRecordTone(record, personnelMeta); + const workdayMeta = getWorkdayMeta(record, personnelMeta); + setExpandedRecordKey(key); + setDayEventsRecordPreview({ + recordId, + employeeName: record.display_name || displayUserName(currentUser) || `Сотрудник ${employeeId}`, + date: `${record.date || "Дата не указана"} · ${formatDateLabel(record.date)}`, + statusLabel: tone.label, + displayText: hasWorked(record) + ? `${formatTime(record.arrival_time)}/${formatTime(record.departure_time)} · ${formatHours(record.work_hours)} ч.` + : "Нет проходов", + detailLines: attendanceRecordDetails(record, workdayMeta), + issues: issueLabels(record, personnelMeta).map(formatAttendanceIssueLabel), + isManuallyEdited: Boolean(record.is_manually_edited), + commentsCount: Number(record.comments_count || 0), + }); + } + async function addComment(recordId: number) { const text = (commentDrafts[recordId] || "").trim(); if (!text) return; try { setBusyKey(`comment-${recordId}`); - const saved = await apiClient.addAttendanceRecordComment(recordId, text); + const saved = await addAttendanceRecordComment(recordId, text); const nextCount = (commentsMap[recordId]?.length || 0) + 1; setCommentsMap((current) => { return { ...current, [recordId]: [...(current[recordId] || []), saved] }; @@ -608,7 +802,7 @@ export default function EmployeeAttendanceCard({ async function deleteComment(recordId: number, commentId: number) { try { setBusyKey(`delete-comment-${commentId}`); - await apiClient.deleteAttendanceRecordComment(recordId, commentId); + await deleteAttendanceRecordComment(recordId, commentId); const nextCount = Math.max((commentsMap[recordId]?.length || 1) - 1, 0); setCommentsMap((current) => { return { @@ -677,7 +871,7 @@ export default function EmployeeAttendanceCard({ try { setBusyKey("edit-record"); - const saved = await apiClient.updateAttendanceRecord(editForm.recordId, payload); + const saved = await updateAttendanceRecord(editForm.recordId, payload); setResult((current) => { if (!current?.records) return current; return { @@ -828,6 +1022,7 @@ export default function EmployeeAttendanceCard({ const recordId = getRecordId(record); const key = recordId ? `record-${recordId}` : `${record.date || "record"}-${index}`; const personnelMeta = getPersonnelDayMeta(employeeActions, record.date, record); + const suppressAttendanceIssues = personnelMeta?.nonWorking || isRemotePersonnelMeta(personnelMeta); const labels = issueLabels(record, personnelMeta); const tone = getRecordTone(record, personnelMeta); const workdayMeta = getWorkdayMeta(record, personnelMeta); @@ -975,10 +1170,22 @@ export default function EmployeeAttendanceCard({ {expanded ? (
-

Детали

+
+

Детали

+ {recordId ? ( + + ) : null} +
- {personnelMeta?.nonWorking ? ( + {personnelMeta ? (

Кадровое состояние

@@ -989,19 +1196,19 @@ export default function EmployeeAttendanceCard({

Опоздание

- {record.is_late && !personnelMeta?.nonWorking ? `${record.late_minutes ?? 0} мин.` : "Нет"} + {record.is_late && !suppressAttendanceIssues ? `${record.late_minutes ?? 0} мин.` : "Нет"}

Ранний уход

- {record.is_early_leave && !personnelMeta?.nonWorking ? `${record.early_leave_minutes ?? 0} мин.` : "Нет"} + {record.is_early_leave && !suppressAttendanceIssues ? `${record.early_leave_minutes ?? 0} мин.` : "Нет"}

Недоработка

- {record.is_underwork && !personnelMeta?.nonWorking ? `${formatHours(record.underwork_hours)} ч.` : "Нет"} + {record.is_underwork && !suppressAttendanceIssues ? `${formatHours(record.underwork_hours)} ч.` : "Нет"}

@@ -1036,6 +1243,13 @@ export default function EmployeeAttendanceCard({
+ setDayEventsRecordPreview(null)} + onOpenComments={(record) => void openCommentsFromDayEvents(record)} + record={dayEventsRecordPreview} + /> + setEditModalOpen(false)} diff --git a/frontend/src/components/requests/RequestStatisticsPanel.tsx b/frontend/src/components/requests/RequestStatisticsPanel.tsx index 21ae4759..6ce4e75e 100644 --- a/frontend/src/components/requests/RequestStatisticsPanel.tsx +++ b/frontend/src/components/requests/RequestStatisticsPanel.tsx @@ -46,9 +46,6 @@ export function RequestStatisticsPanel({ } let cancelled = false; - setLoading(true); - setError(null); - const query = new URLSearchParams({ employee_id: String(employeeId), period, @@ -58,9 +55,16 @@ export function RequestStatisticsPanel({ query.set("date_to", dateTo); } - apiClient.request( - `/api/v1/requests/statistics/?${query.toString()}` - ) + void Promise.resolve() + .then(() => { + if (!cancelled) { + setLoading(true); + setError(null); + } + return apiClient.request( + `/api/v1/requests/statistics/?${query.toString()}` + ); + }) .then((response) => { if (!cancelled) { setStats(response); @@ -102,8 +106,10 @@ export function RequestStatisticsPanel({ { label: "Всего заявлений", value: stats.total_submitted_requests }, { label: "Больничных", value: stats.sick_leave_requests_count }, { label: "Отгулов", value: stats.day_off_requests_count }, + { label: "Декретных", value: stats.maternity_requests_count }, { label: "Дней на больничном", value: stats.sick_leave_days }, { label: "Дней на отгулах", value: stats.day_off_days }, + { label: "Дней в декрете", value: stats.maternity_days }, { label: "Оплачиваемый отпуск, дней", value: stats.paid_vacation_days }, { label: "За свой счёт, дней", value: stats.unpaid_vacation_days }, ] : []; diff --git a/frontend/src/components/users/EmployeeActionModal.tsx b/frontend/src/components/users/EmployeeActionModal.tsx index 2dba57a1..48dbb19f 100644 --- a/frontend/src/components/users/EmployeeActionModal.tsx +++ b/frontend/src/components/users/EmployeeActionModal.tsx @@ -1,9 +1,10 @@ "use client"; import { Modal } from "@/components/ui"; -import type { - EmployeeActionField, - EmployeeActionForm, +import { + isTemporaryEmployeeAction, + type EmployeeActionField, + type EmployeeActionForm, } from "@/lib/users/userDetailUtils"; type EmployeeActionOption = { @@ -31,6 +32,8 @@ export default function EmployeeActionModal({ onSave, }: EmployeeActionModalProps) { const isSaving = actionLoading === "action"; + const showDateTo = isTemporaryEmployeeAction(form.type); + const canSave = Boolean(form.type && form.date && (!showDateTo || form.dateTo)); const footerContent = (
@@ -45,7 +48,7 @@ export default function EmployeeActionModal({