Normalize personnel events and attendance state
This commit is contained in:
parent
2bf79f6c67
commit
3a20c3d86a
@ -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 "Выходной по графику/календарю"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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],
|
||||
)
|
||||
|
||||
@ -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]
|
||||
)
|
||||
|
||||
@ -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(
|
||||
{
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",)
|
||||
|
||||
@ -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:
|
||||
|
||||
122
backend/employees/migrations/0050_employeeaction_date_to.py
Normal file
122
backend/employees/migrations/0050_employeeaction_date_to.py
Normal 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),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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()
|
||||
|
||||
204
backend/employees/services/personnel_state.py
Normal file
204
backend/employees/services/personnel_state.py
Normal 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
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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", "Другое"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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="Тип заявления",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
_("Для выбранного типа укажите даты начала и окончания.")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
204
backend/tests/employees/test_personnel_state.py
Normal file
204
backend/tests/employees/test_personnel_state.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
@ -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 },
|
||||
] : [];
|
||||
|
||||
@ -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)]">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user