Normalize personnel events and attendance state

This commit is contained in:
lizerk 2026-04-29 09:54:11 +03:00
parent 2bf79f6c67
commit 3a20c3d86a
44 changed files with 2511 additions and 496 deletions

View File

@ -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 "Выходной по графику/календарю"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", "Другое"

View File

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

View File

@ -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="Тип заявления",
),
),
]

View File

@ -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(
_("Для выбранного типа укажите даты начала и окончания.")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Pick<
AttendanceAutoSyncSettings,
"enabled" | "frequency_minutes" | "lookback_days"
>>;
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<PaginatedEmployees> {
const client = apiClient as typeof apiClient & {
getEmployees?: (params?: EmployeeQuery) => Promise<PaginatedEmployees>;
};
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<PaginatedEmployees>(
`/api/v1/employees/${query ? `?${query}` : ""}`,
);
}
function getStandardWorkSchedule() {
const client = apiClient as typeof apiClient & {
getStandardWorkSchedule?: () => Promise<StandardWorkSchedule>;
};
if (typeof client.getStandardWorkSchedule === "function") {
return client.getStandardWorkSchedule();
}
return apiClient.request<StandardWorkSchedule>(
"/api/v1/attendance/standard-work-schedule/",
);
}
function updateStandardWorkSchedule(data: Partial<AttendanceSchedulePayload>) {
const client = apiClient as typeof apiClient & {
updateStandardWorkSchedule?: (
data: Partial<AttendanceSchedulePayload>,
) => Promise<StandardWorkSchedule>;
};
if (typeof client.updateStandardWorkSchedule === "function") {
return client.updateStandardWorkSchedule(data);
}
return apiClient.request<StandardWorkSchedule>(
"/api/v1/attendance/standard-work-schedule/",
{
method: "PATCH",
body: JSON.stringify(data),
},
);
}
function getAttendanceAutoSyncSettings() {
const client = apiClient as typeof apiClient & {
getAttendanceAutoSyncSettings?: () => Promise<AttendanceAutoSyncSettings>;
};
if (typeof client.getAttendanceAutoSyncSettings === "function") {
return client.getAttendanceAutoSyncSettings();
}
return apiClient.request<AttendanceAutoSyncSettings>(
"/api/v1/attendance/auto-sync-settings/",
);
}
function updateAttendanceAutoSyncSettings(
data: AutoSyncSettingsPatch,
) {
const client = apiClient as typeof apiClient & {
updateAttendanceAutoSyncSettings?: (
data: AutoSyncSettingsPatch,
) => Promise<AttendanceAutoSyncSettings>;
};
if (typeof client.updateAttendanceAutoSyncSettings === "function") {
return client.updateAttendanceAutoSyncSettings(data);
}
return apiClient.request<AttendanceAutoSyncSettings>(
"/api/v1/attendance/auto-sync-settings/",
{
method: "PATCH",
body: JSON.stringify(data),
},
);
}
function runAttendanceAutoSyncNow() {
const client = apiClient as typeof apiClient & {
runAttendanceAutoSyncNow?: () => Promise<AttendanceAutoSyncSettings>;
};
if (typeof client.runAttendanceAutoSyncNow === "function") {
return client.runAttendanceAutoSyncNow();
}
return apiClient.request<AttendanceAutoSyncSettings>(
"/api/v1/attendance/auto-sync/run-now/",
{ method: "POST" },
);
}
async function downloadMonthlyAttendanceMatrix(
params: AttendanceMatrixExportQuery,
): Promise<AttendanceMatrixExportFile> {
const client = apiClient as typeof apiClient & {
downloadMonthlyAttendanceMatrix?: (
params: AttendanceMatrixExportQuery,
) => Promise<AttendanceMatrixExportFile>;
getToken?: () => string | null;
};
if (typeof client.downloadMonthlyAttendanceMatrix === "function") {
return client.downloadMonthlyAttendanceMatrix(params);
}
const headers: Record<string, string> = {};
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<AttendanceAnalysisResponse>;
};
if (typeof client.analyzeAttendance === "function") {
return client.analyzeAttendance(data);
}
return apiClient.request<AttendanceAnalysisResponse>(
"/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<MonthlyAttendanceMatrix>;
};
if (typeof client.getMonthlyAttendanceMatrix === "function") {
return client.getMonthlyAttendanceMatrix(params);
}
return apiClient.request<MonthlyAttendanceMatrix>(
`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`,
);
}
function getAttendanceRecord(recordId: number) {
const client = apiClient as typeof apiClient & {
getAttendanceRecord?: (recordId: number) => Promise<AttendanceRecord>;
};
if (typeof client.getAttendanceRecord === "function") {
return client.getAttendanceRecord(recordId);
}
return apiClient.request<AttendanceRecord>(`/api/v1/attendance/records/${recordId}/`);
}
function formatOptionalDateTime(value?: string | null) {
return value ? formatDateTime(value) : "-";
}
@ -406,7 +631,7 @@ export default function AttendancePage() {
useState<AttendanceRecordCommentsPreview | null>(null);
const [dayEventsRecordPreview, setDayEventsRecordPreview] =
useState<AttendanceDayEventsPreview | null>(null);
const [openedRecordFromUrl, setOpenedRecordFromUrl] = useState(false);
const [openedRecordFromUrl, setOpenedRecordFromUrl] = useState<string | null>(null);
const [statsModalOpen, setStatsModalOpen] = useState(false);
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
const actionsMenuRef = useRef<HTMLDivElement>(null);
@ -425,7 +650,7 @@ export default function AttendancePage() {
setLoadingEmployees(true);
setError(null);
const nextEmployees = await loadAllPages<User>((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}
/>
<AttendanceDayEventsModal
isOpen={Boolean(dayEventsRecordPreview)}
onClose={() => setDayEventsRecordPreview(null)}
onOpenComments={openCommentsFromDayEvents}
record={dayEventsRecordPreview}
/>
</>

View File

@ -116,11 +116,11 @@ export default function ProfilePage() {
<ProfileHeroCard
caption="Мой профиль"
statusBadge={
currentAction ? (
user.personnel_state ? (
<span
className={`app-status-pill ${getEmployeeActionTone(currentAction.action).badgeClass}`}
className={`app-status-pill ${getEmployeeActionTone(user.personnel_state.status).badgeClass}`}
>
{currentAction.action_display || currentAction.action}
{user.personnel_state.label || user.personnel_state.status}
</span>
) : 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() {
<ProfileWorkScheduleCard
canEdit={Boolean(user.auth?.is_staff || user.auth?.is_superuser)}
currentAction={currentAction}
employeeId={user.id}
personnelState={user.personnel_state}
/>
<EmployeeAttendanceCard employeeActions={user.actions} employeeId={user.id} />

View File

@ -353,16 +353,16 @@ export default function UserDetailPage() {
<ProfileHeroCard
caption="Профиль сотрудника"
statusBadge={
(latestAction || canEdit || canManageActions) ? (
(person.personnel_state || latestAction || canEdit || canManageActions) ? (
<div
ref={profileMenuOpen ? profileMenuRef : null}
className="relative flex items-center gap-2"
>
{latestAction ? (
{person.personnel_state ? (
<span
className={`app-status-pill ${getEmployeeActionTone(latestAction.action).badgeClass}`}
className={`app-status-pill ${getEmployeeActionTone(person.personnel_state.status).badgeClass}`}
>
{latestAction.action_display || latestAction.action}
{person.personnel_state.label || person.personnel_state.status}
</span>
) : 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() {
<ProfileWorkScheduleCard
canEdit={Boolean(currentUser?.auth?.is_staff || currentUser?.auth?.is_superuser)}
currentAction={latestAction}
employeeId={person.id}
personnelState={person.personnel_state}
/>
<EmployeeAttendanceCard employeeActions={person.actions} employeeId={person.id} />

View File

@ -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<AttendanceDayEvent[]>;
};
if (typeof client.getAttendanceRecordDayEvents === "function") {
return client.getAttendanceRecordDayEvents(recordId);
}
return apiClient.request<AttendanceDayEvent[]>(
`/api/v1/attendance/records/${recordId}/day-events/`,
);
}
async function getAttendanceDayEventPhoto(photoUrl: string) {
const client = apiClient as typeof apiClient & {
getAttendanceDayEventPhoto?: (photoUrl: string) => Promise<Blob>;
getToken?: () => string | null;
};
if (typeof client.getAttendanceDayEventPhoto === "function") {
return client.getAttendanceDayEventPhoto(photoUrl);
}
const headers: Record<string, string> = {};
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<string, string>) {
Object.values(urls).forEach((url) => URL.revokeObjectURL(url));
}
@ -35,6 +79,7 @@ function revokePhotoUrls(urls: Record<string, string>) {
export function AttendanceDayEventsModal({
isOpen,
onClose,
onOpenComments,
record,
}: AttendanceDayEventsModalProps) {
const [events, setEvents] = useState<AttendanceDayEvent[]>([]);
@ -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 (
<Modal
isOpen={isOpen && !!record}
@ -123,49 +174,25 @@ export function AttendanceDayEventsModal({
>
{record ? (
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Запись посещения</p>
<div className="mt-2 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{record.detailLines?.length ? (
<div className="mt-3 grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{record.detailLines.slice(0, 8).map((line) => (
<p key={line}>{line}</p>
))}
</div>
<AttendanceRecordHeader
actions={onOpenComments ? (
<button
type="button"
onClick={openComments}
className="app-action-ghost inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<MessageSquare size={15} />
Комментарии{record.commentsCount !== undefined ? ` (${record.commentsCount})` : ""}
</button>
) : null}
{record.issues?.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{record.issues.map((issue) => (
<span key={issue} className="app-status-pill bg-amber-500/15 text-amber-300">
{issue}
</span>
))}
</div>
) : null}
{record.isManuallyEdited ? (
<div className="mt-3 inline-flex rounded-lg border border-violet-400/30 bg-violet-500/10 px-2.5 py-1 text-xs font-medium text-violet-200">
Ручная корректировка EUSRR
</div>
) : null}
</section>
record={record}
/>
<section className="app-surface rounded-xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Camera size={16} className="app-text-muted" />
<p className="app-card-caption">События LogStorm за день</p>
<p className="app-card-caption">Подробности события за день</p>
</div>
{loadingPhotos ? (
<span className="app-text-muted inline-flex items-center gap-2 text-xs">
@ -190,7 +217,7 @@ export function AttendanceDayEventsModal({
Событий за день нет
</p>
<p className="app-text-muted mt-2 text-sm">
LogStorm не вернул проходы для этой даты.
Проходы за эту дату не найдены.
</p>
</div>
) : (

View File

@ -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<AttendanceRecordComment[]>;
request?: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
};
if (typeof client.getAttendanceRecordComments === "function") {
return client.getAttendanceRecordComments(recordId);
}
return client.request?.<AttendanceRecordComment[]>(
`/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<AttendanceRecordComment>;
request?: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
};
if (typeof client.addAttendanceRecordComment === "function") {
return client.addAttendanceRecordComment(recordId, text);
}
return client.request?.<AttendanceRecordComment>(
`/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<void>;
request?: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
};
if (typeof client.deleteAttendanceRecordComment === "function") {
return client.deleteAttendanceRecordComment(recordId, commentId);
}
return client.request?.<void>(
`/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<AttendanceRecordComment[]>([]);
const [commentsLoaded, setCommentsLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
isOpen={isOpen && !!record}
@ -175,34 +255,22 @@ export function AttendanceRecordCommentsModal({
{record ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 space-y-4 px-4 pb-4 sm:px-6">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Запись посещения</p>
<div className="mt-2 space-y-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{record.detailLines?.length ? (
<div className="grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{record.detailLines.slice(0, 6).map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>Комментариев: {Math.max(comments.length, record.commentsCount || 0)}</span>
</div>
</div>
</section>
<AttendanceRecordHeader
actions={onOpenDayEvents ? (
<button
type="button"
onClick={openDayEvents}
className="app-action-ghost inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium"
>
<Camera size={15} />
Подробности события
</button>
) : null}
record={{
...record,
commentsCount: displayedCommentsCount,
}}
/>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">

View File

@ -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 (
<section className="app-surface-muted rounded-xl p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="app-card-caption">Запись посещения</p>
<h3 className="app-text-wrap mt-3 text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{details.length ? (
<div className="mt-3 grid gap-x-8 gap-y-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{details.slice(0, 8).map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-2">
{record.commentsCount !== undefined ? (
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--muted-foreground)]">
Комментариев: {record.commentsCount}
</span>
) : null}
{record.issues?.map((issue) => (
<span key={issue} className="app-status-pill bg-amber-500/15 text-amber-300">
{issue}
</span>
))}
{record.isManuallyEdited ? (
<span className="app-status-pill border border-violet-400/30 bg-violet-500/10 text-violet-200">
Ручная корректировка EUSRR
</span>
) : null}
</div>
{actions ? (
<div className="mt-4 flex flex-wrap gap-2">
{actions}
</div>
) : null}
</section>
);
}

View File

@ -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<typeof getWorkdayMeta>) {
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: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
addAttendanceRecordComment?: unknown;
analyzeAttendance?: unknown;
deleteAttendanceRecordComment?: unknown;
getAttendanceRecordComments?: unknown;
getAttendanceRecords?: unknown;
updateAttendanceRecord?: unknown;
};
function buildAttendanceQuery(params?: Record<string, string | number | undefined>) {
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<AttendanceAnalysisResponse>;
}
return client.request<AttendanceAnalysisResponse>(
"/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<PaginatedAttendanceRecords>;
}
return client.request<PaginatedAttendanceRecords>(
`/api/v1/attendance/records/${buildAttendanceQuery(params)}`,
);
}
function getAttendanceRecordComments(recordId: number) {
const client = attendanceApiClient();
if (typeof client.getAttendanceRecordComments === "function") {
return client.getAttendanceRecordComments(recordId) as Promise<AttendanceRecordComment[]>;
}
return client.request<AttendanceRecordComment[]>(
`/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<AttendanceRecordComment>;
}
return client.request<AttendanceRecordComment>(
`/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<void>;
}
return client.request<void>(
`/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<AttendanceRecord>;
}
return client.request<AttendanceRecord>(
`/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<Record<number, AttendanceRecordComment[]>>({});
const [commentsLoadingMap, setCommentsLoadingMap] = useState<Record<number, boolean>>({});
const [commentDrafts, setCommentDrafts] = useState<Record<number, string>>({});
const [dayEventsRecordPreview, setDayEventsRecordPreview] =
useState<AttendanceDayEventsPreview | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editForm, setEditForm] = useState<AttendanceRecordEditForm | null>(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 ? (
<div className="app-surface mt-3 rounded-lg p-3">
<p className="app-card-caption mb-3">Детали</p>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="app-card-caption">Детали</p>
{recordId ? (
<button
type="button"
onClick={() => openDayEvents(record, recordId, key)}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold"
>
<Camera size={14} />
Подробности события
</button>
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="grid gap-2 sm:grid-cols-2">
{personnelMeta?.nonWorking ? (
{personnelMeta ? (
<div className="sm:col-span-2">
<p className="app-text-muted text-xs">Кадровое состояние</p>
<p className={`mt-1 text-sm font-medium ${personnelMeta.className}`}>
@ -989,19 +1196,19 @@ export default function EmployeeAttendanceCard({
<div>
<p className="app-text-muted text-xs">Опоздание</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{record.is_late && !personnelMeta?.nonWorking ? `${record.late_minutes ?? 0} мин.` : "Нет"}
{record.is_late && !suppressAttendanceIssues ? `${record.late_minutes ?? 0} мин.` : "Нет"}
</p>
</div>
<div>
<p className="app-text-muted text-xs">Ранний уход</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{record.is_early_leave && !personnelMeta?.nonWorking ? `${record.early_leave_minutes ?? 0} мин.` : "Нет"}
{record.is_early_leave && !suppressAttendanceIssues ? `${record.early_leave_minutes ?? 0} мин.` : "Нет"}
</p>
</div>
<div>
<p className="app-text-muted text-xs">Недоработка</p>
<p className="mt-1 text-sm text-[var(--foreground)]">
{record.is_underwork && !personnelMeta?.nonWorking ? `${formatHours(record.underwork_hours)} ч.` : "Нет"}
{record.is_underwork && !suppressAttendanceIssues ? `${formatHours(record.underwork_hours)} ч.` : "Нет"}
</p>
</div>
<div>
@ -1036,6 +1243,13 @@ export default function EmployeeAttendanceCard({
</div>
</section>
<AttendanceDayEventsModal
isOpen={Boolean(dayEventsRecordPreview)}
onClose={() => setDayEventsRecordPreview(null)}
onOpenComments={(record) => void openCommentsFromDayEvents(record)}
record={dayEventsRecordPreview}
/>
<Modal
isOpen={editModalOpen}
onClose={() => setEditModalOpen(false)}

View File

@ -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<RequestEmployeeStatistics>(
`/api/v1/requests/statistics/?${query.toString()}`
)
void Promise.resolve()
.then(() => {
if (!cancelled) {
setLoading(true);
setError(null);
}
return apiClient.request<RequestEmployeeStatistics>(
`/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 },
] : [];

View File

@ -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 = (
<div className="flex gap-3">
@ -45,7 +48,7 @@ export default function EmployeeActionModal({
<button
type="button"
onClick={onSave}
disabled={isSaving || !form.type || !form.date}
disabled={isSaving || !canSave}
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
{isSaving ? "Сохранение..." : "Сохранить"}
@ -99,6 +102,24 @@ export default function EmployeeActionModal({
</label>
</section>
{showDateTo ? (
<section className="app-surface-muted rounded-xl p-4">
<label htmlFor="action-date-to" className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Дата окончания *
</span>
<input
id="action-date-to"
type="date"
value={form.dateTo}
min={form.date}
onChange={(event) => onFieldChange("dateTo", event.target.value)}
className="app-input w-full rounded-lg px-4 py-2.5 text-sm"
/>
</label>
</section>
) : null}
<section className="app-surface-muted rounded-xl p-4">
<label htmlFor="action-comment" className="block">
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">

View File

@ -6,7 +6,7 @@ import {
getEmployeeActionTone,
truncateText,
} from "@/lib/users/userDetailUtils";
import type { EmployeeAction } from "@/types/api";
import type { EmployeeAction, EmployeePersonnelState } from "@/types/api";
type EmployeeActionsTimelineProps = {
actionLoading: string | null;
@ -16,6 +16,7 @@ type EmployeeActionsTimelineProps = {
onAddAction?: () => void;
onDeleteAction?: (actionId: number) => void;
onEditAction?: (action: EmployeeAction) => void;
personnelState?: EmployeePersonnelState | null;
sortedActions: EmployeeAction[];
initialVisibleCount?: number;
showCountLabel?: boolean;
@ -33,6 +34,11 @@ function formatActionDate(value: string) {
});
}
function formatStateDate(value?: string | null) {
if (!value) return "";
return formatActionDate(value);
}
export default function EmployeeActionsTimeline({
actionLoading,
canManageActions,
@ -41,6 +47,7 @@ export default function EmployeeActionsTimeline({
onAddAction,
onDeleteAction,
onEditAction,
personnelState,
sortedActions,
initialVisibleCount,
showCountLabel = true,
@ -48,6 +55,12 @@ export default function EmployeeActionsTimeline({
expandedCommentLength = truncateCommentLength,
}: EmployeeActionsTimelineProps) {
const [showAll, setShowAll] = useState(false);
const currentActionId = personnelState ? personnelState.action_id : latestActionId;
const showCurrentStateCard = Boolean(
personnelState
&& personnelState.status
&& personnelState.status !== "normal"
);
const canCollapse = Boolean(
initialVisibleCount && sortedActions.length > initialVisibleCount,
);
@ -59,7 +72,7 @@ export default function EmployeeActionsTimeline({
[canCollapse, initialVisibleCount, showAll, sortedActions],
);
if (!canViewActions || sortedActions.length === 0) {
if (!canViewActions || (sortedActions.length === 0 && !showCurrentStateCard)) {
return null;
}
@ -105,9 +118,40 @@ export default function EmployeeActionsTimeline({
</p>
) : null}
{showCurrentStateCard && personnelState ? (
<div className="mb-4 rounded-xl border border-[var(--accent-primary)]/30 bg-[var(--accent-primary)]/10 px-4 py-3">
{(() => {
const tone = getEmployeeActionTone(personnelState.status);
const dateFrom = formatStateDate(personnelState.date_from);
const dateTo = formatStateDate(personnelState.date_to);
return (
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="app-card-caption mb-2">Текущее состояние</p>
<div className="flex flex-wrap items-center gap-2">
<span className={`app-status-pill ${tone.badgeClass}`}>
{personnelState.label || personnelState.status}
</span>
<span className="app-text-muted text-xs font-medium">
активно сейчас
</span>
</div>
</div>
{dateFrom ? (
<time className="app-text-muted shrink-0 pt-1 text-sm">
{dateFrom}
{dateTo ? ` - ${dateTo}` : ""}
</time>
) : null}
</div>
);
})()}
</div>
) : null}
<div className="space-y-3">
{visibleActions.map((action) => {
const isCurrent = latestActionId === action.id;
const isCurrent = currentActionId === action.id;
const deleteKey = `delete-${action.id}`;
const tone = getEmployeeActionTone(action.action);
const commentText = truncateCommentLength
@ -148,6 +192,7 @@ export default function EmployeeActionsTimeline({
<div className="flex shrink-0 items-start gap-2">
<time className="app-text-muted pt-1 text-sm">
{formatActionDate(action.date)}
{action.date_to ? ` - ${formatActionDate(action.date_to)}` : ""}
</time>
{canManageActions && onEditAction && onDeleteAction ? (
<div className="flex gap-1">

View File

@ -16,7 +16,11 @@ import {
} from "lucide-react";
import { apiClient } from "@/lib/api";
import type { EmployeeWorkSchedule } from "@/lib/api/attendance";
import type { EmployeeAction, EmployeeDepartment, Skill } from "@/types/api";
import type {
EmployeeDepartment,
EmployeePersonnelState,
Skill,
} from "@/types/api";
import { getEmployeeActionTone } from "@/lib/users/userDetailUtils";
export type ProfileContactRow = {
@ -75,6 +79,39 @@ function defaultSchedule(employeeId: number): EmployeeWorkSchedule {
};
}
type WorkScheduleApiClient = typeof apiClient & {
request: <T>(endpoint: string, options?: RequestInit) => Promise<T>;
getEmployeeWorkSchedule?: unknown;
updateEmployeeWorkSchedule?: unknown;
};
function getEmployeeWorkSchedule(employeeId: number) {
const client = apiClient as WorkScheduleApiClient;
if (typeof client.getEmployeeWorkSchedule === "function") {
return client.getEmployeeWorkSchedule(employeeId) as Promise<EmployeeWorkSchedule>;
}
return client.request<EmployeeWorkSchedule>(
`/api/v1/attendance/work-schedules/${employeeId}/`,
);
}
function updateEmployeeWorkSchedule(
employeeId: number,
data: Partial<EmployeeWorkSchedule>,
) {
const client = apiClient as WorkScheduleApiClient;
if (typeof client.updateEmployeeWorkSchedule === "function") {
return client.updateEmployeeWorkSchedule(employeeId, data) as Promise<EmployeeWorkSchedule>;
}
return client.request<EmployeeWorkSchedule>(
`/api/v1/attendance/work-schedules/${employeeId}/`,
{
method: "PATCH",
body: JSON.stringify(data),
},
);
}
function SectionTitle({
title,
action,
@ -251,12 +288,12 @@ export function ProfileWorkScheduleCard({
title = "График работы",
employeeId,
canEdit = false,
currentAction,
personnelState,
}: {
title?: string;
employeeId: number;
canEdit?: boolean;
currentAction?: EmployeeAction | null;
personnelState?: EmployeePersonnelState | null;
}) {
const [schedule, setSchedule] = useState<EmployeeWorkSchedule>(() =>
defaultSchedule(employeeId),
@ -279,7 +316,7 @@ export function ProfileWorkScheduleCard({
try {
setLoading(true);
setError(null);
const data = await apiClient.getEmployeeWorkSchedule(employeeId);
const data = await getEmployeeWorkSchedule(employeeId);
if (!mounted) return;
setSchedule(data);
setForm({
@ -312,8 +349,8 @@ export function ProfileWorkScheduleCard({
const weekendDays = workScheduleDays
.map((day) => day.value)
.filter((day) => !workdays.includes(day));
const actionTone = currentAction
? getEmployeeActionTone(currentAction.action)
const stateTone = personnelState
? getEmployeeActionTone(personnelState.status)
: null;
const toggleFormWorkday = (day: string) => {
@ -330,7 +367,7 @@ export function ProfileWorkScheduleCard({
try {
setSaving(true);
setError(null);
const updated = await apiClient.updateEmployeeWorkSchedule(employeeId, {
const updated = await updateEmployeeWorkSchedule(employeeId, {
start_time: form.start_time,
end_time: form.end_time,
expected_hours: Number(form.expected_hours) || 0,
@ -357,9 +394,9 @@ export function ProfileWorkScheduleCard({
<span className="app-status-pill app-feedback-warning">
По умолчанию
</span>
) : currentAction ? (
<span className={`app-status-pill ${actionTone?.badgeClass || ""}`}>
{currentAction.action_display || currentAction.action}
) : personnelState ? (
<span className={`app-status-pill ${stateTone?.badgeClass || ""}`}>
{personnelState.label || personnelState.status}
</span>
) : (
<span className="app-status-pill app-feedback-success">Активен</span>
@ -531,11 +568,12 @@ export function ProfileWorkScheduleCard({
</div>
</div>
{currentAction ? (
{personnelState?.date_from ? (
<div className="mt-3 flex items-start gap-2 rounded-xl border border-[var(--border-subtle)] px-3 py-2 text-sm">
<CalendarClock size={16} className="mt-0.5 shrink-0 text-[var(--muted-foreground)]" />
<p className="app-text-muted app-text-wrap">
Кадровое состояние с {currentAction.date_display || currentAction.date}
Кадровое состояние с {personnelState.date_from}
{personnelState.date_to ? ` по ${personnelState.date_to}` : ""}
</p>
</div>
) : null}

View File

@ -49,6 +49,7 @@ export const requestTypeLabels: Record<string, string> = {
vacation: "Отпуск",
sick_leave: "Больничный",
day_off: "Отгул",
maternity: "Декрет",
transfer: "Перевод",
dismissal: "Увольнение",
other: "Другое",
@ -66,6 +67,8 @@ export function getRequestDateMode(type: string): "range" | "single" | "optional
switch (String(type || "").toLowerCase()) {
case "vacation":
case "sick_leave":
case "day_off":
case "maternity":
return "range";
case "transfer":
case "dismissal":

View File

@ -11,6 +11,7 @@ import {
getLatestEmployeeAction,
getUserFullName,
getUserInitials,
isTemporaryEmployeeAction,
type EmployeeActionField,
type EmployeeActionForm,
type UserProfileEditForm,
@ -142,7 +143,8 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
setActionForm({
editingId: action.id,
type: action.action,
date: action.date,
date: action.date.split("T")[0],
dateTo: action.date_to || "",
comment: action.comment || "",
});
setIsActionModalOpen(true);
@ -172,6 +174,9 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
await apiClient.updateEmployeeAction(actionForm.editingId, {
action: actionForm.type,
date: actionForm.date,
date_to: isTemporaryEmployeeAction(actionForm.type)
? actionForm.dateTo || null
: null,
comment: actionForm.comment.trim() || undefined,
});
} else {
@ -179,6 +184,9 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
employee: userId,
action: actionForm.type,
date: actionForm.date,
date_to: isTemporaryEmployeeAction(actionForm.type)
? actionForm.dateTo || null
: null,
comment: actionForm.comment.trim() || undefined,
});
}
@ -275,7 +283,13 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
}, []);
const setActionField = useCallback(<K extends EmployeeActionField>(field: K, value: EmployeeActionForm[K]) => {
setActionForm((current) => ({ ...current, [field]: value }));
setActionForm((current) => {
const next = { ...current, [field]: value };
if (field === "type" && !isTemporaryEmployeeAction(String(value))) {
next.dateTo = "";
}
return next;
});
}, []);
const handleSaveEdit = useCallback(async () => {

View File

@ -40,9 +40,9 @@ export function createEmployeesApi(request: RequestFn) {
const fd = new FormData(); fd.append('avatar', file);
return request(`/api/v1/employees/${id}/`, { method: 'PATCH', body: fd });
},
createEmployeeAction: (data: { employee: number; action: string; date: string; comment?: string }) =>
createEmployeeAction: (data: { employee: number; action: string; date: string; date_to?: string | null; comment?: string }) =>
request('/api/v1/employee-actions/', { method: 'POST', body: JSON.stringify(data) }),
updateEmployeeAction: (actionId: number, data: { action?: string; date?: string; comment?: string }) =>
updateEmployeeAction: (actionId: number, data: { action?: string; date?: string; date_to?: string | null; comment?: string }) =>
request(`/api/v1/employee-actions/${actionId}/`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteEmployeeAction: (actionId: number) =>
request(`/api/v1/employee-actions/${actionId}/`, { method: 'DELETE' }),

View File

@ -28,16 +28,19 @@ export type UserProfileEditForm = {
avatarPreview: string | null;
};
export type EmployeeActionField = "type" | "date" | "comment";
export type EmployeeActionField = "type" | "date" | "dateTo" | "comment";
export type EmployeeActionForm = {
type: string;
date: string;
dateTo: string;
comment: string;
editingId: number | null;
};
export const employeeActionTypes = [
{ value: "working", label: "Работает" },
{ value: "remote", label: "На удалёнке" },
{ value: "hired", label: "Принят" },
{ value: "dismissed", label: "Уволен" },
{ value: "on_leave", label: "В отпуске" },
@ -52,9 +55,17 @@ export const employeeActionTypes = [
{ value: "rehired", label: "Восстановлен" },
] as const;
export const temporaryEmployeeActionTypes = new Set([
"on_leave",
"on_sick_leave",
"on_day_off",
"on_maternity",
]);
export const createEmptyActionForm = (): EmployeeActionForm => ({
type: "",
date: new Date().toISOString().split("T")[0],
dateTo: "",
comment: "",
editingId: null,
});
@ -129,6 +140,10 @@ export function sortEmployeeActions(actions?: EmployeeAction[] | null): Employee
));
}
export function isTemporaryEmployeeAction(actionType?: string): boolean {
return Boolean(actionType && temporaryEmployeeActionTypes.has(actionType));
}
export function formatPhoneForLink(phone: string): string {
return phone.replace(/[^0-9+]/g, "");
}
@ -229,6 +244,7 @@ export function getEmployeeActionTone(actionType?: string): {
lineColor: "#f59e0b",
};
case "transferred":
case "remote":
return {
badgeClass: "app-selected",
lineColor: "#38bdf8",
@ -239,9 +255,12 @@ export function getEmployeeActionTone(actionType?: string): {
lineColor: "#ef4444",
};
case "returned_from_leave":
case "returned_from_sick_leave":
case "returned_from_day_off":
case "returned_from_maternity":
case "rehired":
case "hired":
case "working":
default:
return {
badgeClass: "app-feedback-success",

View File

@ -11,13 +11,23 @@ export interface EmployeeAction {
action: string;
action_display?: string;
date: string;
date_to?: string | null;
date_display?: string;
comment?: string;
extra?: Record<string, any>;
history?: any;
extra?: Record<string, unknown>;
history?: unknown;
created_at?: string;
}
export interface EmployeePersonnelState {
status: string;
label: string;
action_id: number | null;
date_from: string | null;
date_to: string | null;
expects_attendance: boolean;
}
export interface EmployeeDepartment {
id: number;
name: string;
@ -46,6 +56,7 @@ export interface User {
birth_date?: string;
skills?: Skill[];
actions?: EmployeeAction[];
personnel_state?: EmployeePersonnelState;
email_verified?: boolean;
is_ldap_managed?: boolean;
created_at?: string;
@ -302,7 +313,7 @@ export interface DocumentVersion {
created_at: string;
user: string;
comment: string;
changes: Record<string, any>;
changes: Record<string, unknown>;
}
export interface DocumentActivity {
@ -311,7 +322,7 @@ export interface DocumentActivity {
user: string;
action: string;
description: string;
related_object?: any;
related_object?: unknown;
}
export interface RevertDocumentData {
@ -331,7 +342,7 @@ export interface RelatedDocument {
export interface Request {
id: number;
title: string;
type?: 'vacation' | 'sick_leave' | 'day_off' | 'transfer' | 'dismissal' | 'other';
type?: 'vacation' | 'sick_leave' | 'day_off' | 'maternity' | 'transfer' | 'dismissal' | 'other';
request_type?: string;
display_title?: string;
description?: string;
@ -378,8 +389,10 @@ export interface RequestEmployeeStatistics {
total_submitted_requests: number;
sick_leave_requests_count: number;
day_off_requests_count: number;
maternity_requests_count: number;
sick_leave_days: number;
day_off_days: number;
maternity_days: number;
paid_vacation_days: number;
unpaid_vacation_days: number;
}
@ -598,8 +611,8 @@ export interface Chat {
is_blocked?: boolean;
blocked_at?: string | null;
blocked_by?: number | null;
flags?: Record<string, any>;
extra_data?: Record<string, any>;
flags?: Record<string, unknown>;
extra_data?: Record<string, unknown>;
context_object_id?: number | null;
context_type?: string | null;
context_app?: string | null;
@ -805,7 +818,7 @@ export interface Notification {
unread: boolean;
timestamp: string;
action_url?: string;
data?: Record<string, any>;
data?: Record<string, unknown>;
// Optional actor/target
actor?: {
type: string;
@ -875,7 +888,7 @@ export interface SearchResult {
object_id: number;
title: string;
description?: string;
meta?: Record<string, any>;
meta?: Record<string, unknown>;
}
export interface SearchResponse {