From 8e11b7fbba40b783f7bafb21906741b037242a52 Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 22 Apr 2026 18:02:45 +0300 Subject: [PATCH] Improve attendance reporting and event details --- backend/api/v1/attendance/serializers.py | 96 ++- backend/api/v1/attendance/views.py | 248 +++++- backend/api/v1/urls.py | 24 + backend/attendance/admin.py | 13 + .../migrations/0005_standardworkschedule.py | 92 +++ backend/attendance/models.py | 47 ++ backend/attendance/services.py | 465 ++++++++++- backend/common/logstorm_client.py | 55 +- .../v1/attendance/test_attendance_storage.py | 566 +++++++++++++ .../test_logstorm_attendance_api.py | 10 +- .../tests/integration/test_logstorm_client.py | 54 +- frontend/src/app/api/[...path]/route.ts | 14 +- frontend/src/app/attendance/page.tsx | 779 ++++++++++++++---- frontend/src/components/AppShell.tsx | 8 +- .../attendance/AttendanceDayEventsModal.tsx | 244 ++++++ .../AttendanceRecordCommentsModal.tsx | 334 ++++++++ frontend/src/lib/api/attendance.ts | 104 ++- frontend/src/lib/api/index.ts | 2 +- 18 files changed, 2969 insertions(+), 186 deletions(-) create mode 100644 backend/attendance/migrations/0005_standardworkschedule.py create mode 100644 frontend/src/components/attendance/AttendanceDayEventsModal.tsx create mode 100644 frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx diff --git a/backend/api/v1/attendance/serializers.py b/backend/api/v1/attendance/serializers.py index 06eb8b04..0b0f0112 100644 --- a/backend/api/v1/attendance/serializers.py +++ b/backend/api/v1/attendance/serializers.py @@ -4,8 +4,9 @@ from attendance.models import ( AttendanceAnalysisRun, AttendanceRecord, EmployeeWorkSchedule, + StandardWorkSchedule, ) -from attendance.services import get_default_work_schedule_payload +from attendance.services import get_standard_work_schedule_payload from api.v1.employees.serializers import EmployeeBriefSerializer @@ -90,8 +91,51 @@ class EmployeeWorkScheduleSerializer(serializers.ModelSerializer): return value +class StandardWorkScheduleSerializer(serializers.ModelSerializer): + is_default = serializers.BooleanField(read_only=True, default=False) + + class Meta: + model = StandardWorkSchedule + fields = ( + "id", + "start_time", + "end_time", + "expected_hours", + "workdays", + "date_overrides", + "is_default", + "updated_by", + "created_at", + "updated_at", + ) + read_only_fields = ( + "id", + "is_default", + "updated_by", + "created_at", + "updated_at", + ) + + def validate_workdays(self, value): + allowed = set(StandardWorkSchedule.DEFAULT_WORKDAYS) | {"Saturday", "Sunday"} + if not isinstance(value, list): + raise serializers.ValidationError("workdays must be a list") + normalized = [] + for item in value: + if item not in allowed: + raise serializers.ValidationError(f"Unknown weekday: {item}") + if item not in normalized: + normalized.append(item) + return normalized + + def validate_expected_hours(self, value): + if value <= 0 or value > 24: + raise serializers.ValidationError("expected_hours must be between 0 and 24") + return value + + def default_work_schedule_response(employee_id: int) -> dict: - payload = get_default_work_schedule_payload() + payload = get_standard_work_schedule_payload() return { "id": None, "employee_id": employee_id, @@ -108,6 +152,22 @@ def default_work_schedule_response(employee_id: int) -> dict: } +def default_standard_work_schedule_response() -> dict: + payload = get_standard_work_schedule_payload() + return { + "id": None, + "start_time": payload["start_time"], + "end_time": payload["end_time"], + "expected_hours": payload["expected_hours"], + "workdays": payload["workdays"], + "date_overrides": payload["date_overrides"], + "is_default": True, + "updated_by": None, + "created_at": None, + "updated_at": None, + } + + class AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer): employee_ids = serializers.CharField() month = serializers.RegexField(r"^\d{4}-\d{2}$") @@ -138,6 +198,38 @@ class AttendanceMonthlyMatrixQuerySerializer(serializers.Serializer): return value +class AttendanceMonthlyMatrixExportQuerySerializer(serializers.Serializer): + employee_ids = serializers.CharField() + period_start = serializers.DateField() + period_end = serializers.DateField() + + def validate_employee_ids(self, value): + employee_ids = [] + for raw_item in value.split(","): + item = raw_item.strip() + if not item: + continue + try: + employee_id = int(item) + except ValueError as exc: + raise serializers.ValidationError( + "employee_ids must be a comma-separated list of integers" + ) from exc + if employee_id not in employee_ids: + employee_ids.append(employee_id) + + if not employee_ids: + raise serializers.ValidationError("employee_ids is required") + return employee_ids + + def validate(self, attrs): + if attrs["period_start"] > attrs["period_end"]: + raise serializers.ValidationError( + "period_start must be less than or equal to period_end" + ) + return attrs + + class AttendanceAnalysisRunSerializer(serializers.ModelSerializer): class Meta: model = AttendanceAnalysisRun diff --git a/backend/api/v1/attendance/views.py b/backend/api/v1/attendance/views.py index dc7efec5..a8a8e8a7 100644 --- a/backend/api/v1/attendance/views.py +++ b/backend/api/v1/attendance/views.py @@ -1,11 +1,14 @@ import logging from calendar import monthrange from datetime import date +from urllib.parse import quote -from attendance.models import AttendanceRecord, EmployeeWorkSchedule +from attendance.models import AttendanceRecord, EmployeeWorkSchedule, StandardWorkSchedule from attendance.services import ( + build_attendance_matrix_export_workbook, build_monthly_attendance_matrix, get_employee_work_schedule_payload, + get_standard_work_schedule_payload, normalize_attendance_record_manual_issues, save_logstorm_attendance_result, ) @@ -17,6 +20,7 @@ from common.logstorm_attendance import ( ) from common.logstorm_client import LogStormClient, LogStormClientError from django.contrib.contenttypes.models import ContentType +from django.http import HttpResponse from django.db.models import Count, IntegerField, OuterRef, Subquery, Value from django.db.models.functions import Coalesce from django.shortcuts import get_object_or_404 @@ -30,13 +34,16 @@ from rest_framework.response import Response from rest_framework.views import APIView from .serializers import ( + AttendanceMonthlyMatrixExportQuerySerializer, AttendanceMonthlyMatrixQuerySerializer, AttendanceRecordCommentSerializer, AttendanceRecordSerializer, AttendanceRecordUpdateSerializer, EmployeeWorkScheduleSerializer, LogStormAttendanceAnalyzeSerializer, + StandardWorkScheduleSerializer, default_work_schedule_response, + default_standard_work_schedule_response, ) logger = logging.getLogger(__name__) @@ -51,7 +58,11 @@ def _can_access_employee(user, employee_id) -> bool: return bool( user and user.is_authenticated - and (user.is_staff or normalized_employee_id == int(user.id)) + and ( + user.is_staff + or user.is_superuser + or normalized_employee_id == int(user.id) + ) ) @@ -64,11 +75,16 @@ def _check_employee_access(user, employee_id) -> None: def _records_visible_to(user): queryset = AttendanceRecord.objects.all() - if user.is_staff: + if user.is_staff or user.is_superuser: return queryset return queryset.filter(employee_id=user.id) +def _check_staff_access(user) -> None: + if not (user and user.is_authenticated and (user.is_staff or user.is_superuser)): + raise PermissionDenied("You don't have permission to manage attendance") + + def _comments_count_annotation(): record_ct = ContentType.objects.get_for_model(AttendanceRecord) comments_subquery = ( @@ -90,6 +106,44 @@ def _comments_count_annotation(): } +def _comment_author_name(author) -> str: + if not author: + return "Сотрудник" + first_name = getattr(author, "first_name", "") or "" + last_name = getattr(author, "last_name", "") or "" + full_name = f"{last_name} {first_name}".strip() + return full_name or getattr(author, "email", "") or "Сотрудник" + + +def _attendance_record_comments_map(record_ids: list[int]) -> dict[int, list[dict[str, str]]]: + if not record_ids: + return {} + record_ct = ContentType.objects.get_for_model(AttendanceRecord) + messages = ( + Message.objects.filter( + chat__type="comments", + chat__context_content_type=record_ct, + chat__context_object_id__in=record_ids, + is_deleted=False, + ) + .select_related("author", "chat") + .order_by("created_at", "id") + ) + result: dict[int, list[dict[str, str]]] = {} + for message in messages: + record_id = int(message.chat.context_object_id) + result.setdefault(record_id, []).append( + { + "author": _comment_author_name(message.author), + "text": message.content, + "created_at": timezone.localtime(message.created_at).strftime( + "%d.%m.%Y %H:%M" + ), + } + ) + return result + + class LogStormAttendanceAnalyzeAPIView(APIView): permission_classes = [IsAuthenticated] @@ -109,7 +163,10 @@ class LogStormAttendanceAnalyzeAPIView(APIView): schedule_payload = data.get("schedule") if schedule_payload is None: - schedule_payload = get_employee_work_schedule_payload(employee) + schedule_payload = ( + get_employee_work_schedule_payload(employee) + or get_standard_work_schedule_payload() + ) try: result = analyze_employee_attendance( @@ -163,7 +220,7 @@ class EmployeeWorkScheduleAPIView(APIView): return Response(EmployeeWorkScheduleSerializer(schedule).data) def patch(self, request, employee_id): - if not request.user.is_staff: + if not (request.user.is_staff or request.user.is_superuser): raise PermissionDenied("You don't have permission to edit schedules") employee = get_object_or_404(Employee, pk=employee_id) try: @@ -180,6 +237,29 @@ class EmployeeWorkScheduleAPIView(APIView): return Response(EmployeeWorkScheduleSerializer(instance).data) +class StandardWorkScheduleAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + _check_staff_access(request.user) + schedule = StandardWorkSchedule.objects.order_by("id").first() + if schedule is None: + return Response(default_standard_work_schedule_response()) + return Response(StandardWorkScheduleSerializer(schedule).data) + + def patch(self, request): + _check_staff_access(request.user) + schedule = StandardWorkSchedule.objects.order_by("id").first() + serializer = StandardWorkScheduleSerializer( + schedule, + data=request.data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + instance = serializer.save(updated_by=request.user) + return Response(StandardWorkScheduleSerializer(instance).data) + + class AttendanceRecordListAPIView(ListAPIView): permission_classes = [IsAuthenticated] serializer_class = AttendanceRecordSerializer @@ -257,9 +337,72 @@ class AttendanceMonthlyMatrixAPIView(APIView): ) +class AttendanceMonthlyMatrixExportAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + _check_staff_access(request.user) + serializer = AttendanceMonthlyMatrixExportQuerySerializer( + data=request.query_params, + ) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + employee_ids = data["employee_ids"] + period_start = data["period_start"] + period_end = data["period_end"] + + employees = list(Employee.objects.filter(id__in=employee_ids)) + employees_by_id = {employee.id: employee for employee in employees} + employees = [ + employees_by_id[employee_id] + for employee_id in employee_ids + if employee_id in employees_by_id + ] + + records = list( + _records_visible_to(request.user) + .filter( + employee_id__in=[employee.id for employee in employees], + date__gte=period_start, + date__lte=period_end, + ) + .select_related("employee") + .annotate(**_comments_count_annotation()) + .order_by("employee_id", "date", "id") + ) + + content = build_attendance_matrix_export_workbook( + employees=employees, + records=records, + period_start=period_start, + period_end=period_end, + record_comments=_attendance_record_comments_map( + [record.id for record in records] + ), + ) + filename = f"attendance-{period_start.isoformat()}_{period_end.isoformat()}.xlsx" + response = HttpResponse( + content, + content_type=( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ), + ) + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + class AttendanceRecordDetailAPIView(APIView): permission_classes = [IsAuthenticated] + def get(self, request, record_id): + record = get_object_or_404( + _records_visible_to(request.user) + .select_related("employee", "analysis_run") + .annotate(**_comments_count_annotation()), + pk=record_id, + ) + return Response(AttendanceRecordSerializer(record).data) + def patch(self, request, record_id): if not request.user.is_staff: raise PermissionDenied( @@ -324,6 +467,101 @@ class AttendanceRecordDetailAPIView(APIView): return Response(AttendanceRecordSerializer(record).data) +class AttendanceRecordDayEventsAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get_record(self, request, record_id): + return get_object_or_404( + _records_visible_to(request.user).select_related("employee"), + pk=record_id, + ) + + def get(self, request, record_id): + record = self.get_record(request, record_id) + try: + events = LogStormClient().get_attendance_day_events( + employee_id=str(record.employee_id), + record_date=record.date, + ) + except LogStormClientError as exc: + logger.warning("LogStorm attendance day events failed: %s", exc) + return Response( + {"detail": str(exc), "error": "logstorm_unavailable"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return Response( + [ + self._with_proxy_photo_url(request, record, event) + for event in events + if isinstance(event, dict) + ] + ) + + @staticmethod + def _with_proxy_photo_url(request, record, event): + result = dict(event) + event_key = str(result.get("event_key") or "") + if result.get("has_photo") and event_key: + result["photo_url"] = ( + f"/api/v1/attendance/records/{record.id}/day-events/" + f"{quote(event_key)}/photo/" + ) + else: + result["photo_url"] = None + return result + + +class AttendanceRecordDayEventPhotoAPIView(APIView): + permission_classes = [IsAuthenticated] + + def get_record(self, request, record_id): + return get_object_or_404( + _records_visible_to(request.user).select_related("employee"), + pk=record_id, + ) + + def get(self, request, record_id, event_key): + record = self.get_record(request, record_id) + client = LogStormClient() + try: + events = client.get_attendance_day_events( + employee_id=str(record.employee_id), + record_date=record.date, + ) + event = next( + ( + item + for item in events + if isinstance(item, dict) + and str(item.get("event_key")) == str(event_key) + ), + None, + ) + if not event or not event.get("has_photo"): + return Response( + {"detail": "Event photo not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + photo_response = client.get_attendance_event_photo(event_key) + except LogStormClientError as exc: + if exc.status_code == status.HTTP_404_NOT_FOUND: + return Response( + {"detail": "Event photo not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + logger.warning("LogStorm attendance event photo failed: %s", exc) + return Response( + {"detail": str(exc), "error": "logstorm_unavailable"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + return HttpResponse( + photo_response.content, + content_type=photo_response.headers.get("Content-Type", "image/jpeg"), + ) + + class AttendanceRecordCommentsAPIView(APIView): permission_classes = [IsAuthenticated] diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 76fd9158..4b00c7f0 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -30,13 +30,17 @@ from .feed.views import PostViewSet from .requests_app.views import RequestViewSet from .search.views import search_api_view from .attendance.views import ( + AttendanceMonthlyMatrixExportAPIView, AttendanceMonthlyMatrixAPIView, + AttendanceRecordDayEventPhotoAPIView, + AttendanceRecordDayEventsAPIView, AttendanceRecordCommentDetailAPIView, AttendanceRecordCommentsAPIView, AttendanceRecordDetailAPIView, AttendanceRecordListAPIView, EmployeeWorkScheduleAPIView, LogStormAttendanceAnalyzeAPIView, + StandardWorkScheduleAPIView, ) app_name = "v1" @@ -112,16 +116,36 @@ urlpatterns = [ AttendanceMonthlyMatrixAPIView.as_view(), name="attendance-monthly-matrix", ), + path( + "attendance/monthly-matrix/export/", + AttendanceMonthlyMatrixExportAPIView.as_view(), + name="attendance-monthly-matrix-export", + ), path( "attendance/work-schedules//", EmployeeWorkScheduleAPIView.as_view(), name="attendance-work-schedule", ), + path( + "attendance/standard-work-schedule/", + StandardWorkScheduleAPIView.as_view(), + name="attendance-standard-work-schedule", + ), path( "attendance/records//", AttendanceRecordDetailAPIView.as_view(), name="attendance-record-detail", ), + path( + "attendance/records//day-events/", + AttendanceRecordDayEventsAPIView.as_view(), + name="attendance-record-day-events", + ), + path( + "attendance/records//day-events//photo/", + AttendanceRecordDayEventPhotoAPIView.as_view(), + name="attendance-record-day-event-photo", + ), path( "attendance/records//comments/", AttendanceRecordCommentsAPIView.as_view(), diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py index 5339956c..f55949e6 100644 --- a/backend/attendance/admin.py +++ b/backend/attendance/admin.py @@ -4,6 +4,7 @@ from attendance.models import ( AttendanceAnalysisRun, AttendanceRecord, EmployeeWorkSchedule, + StandardWorkSchedule, ) @@ -65,3 +66,15 @@ class EmployeeWorkScheduleAdmin(admin.ModelAdmin): "employee__email", ) readonly_fields = ("created_at", "updated_at") + + +@admin.register(StandardWorkSchedule) +class StandardWorkScheduleAdmin(admin.ModelAdmin): + list_display = ( + "start_time", + "end_time", + "expected_hours", + "updated_by", + "updated_at", + ) + readonly_fields = ("created_at", "updated_at") diff --git a/backend/attendance/migrations/0005_standardworkschedule.py b/backend/attendance/migrations/0005_standardworkschedule.py new file mode 100644 index 00000000..8a16c6c3 --- /dev/null +++ b/backend/attendance/migrations/0005_standardworkschedule.py @@ -0,0 +1,92 @@ +# Generated by Codex on 2026-04-22 + +import datetime +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("attendance", "0004_employeeworkschedule"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="StandardWorkSchedule", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "singleton", + models.BooleanField(default=True, editable=False, unique=True), + ), + ( + "start_time", + models.TimeField( + default=datetime.time(8, 0), + verbose_name="Начало рабочего дня", + ), + ), + ( + "end_time", + models.TimeField( + default=datetime.time(17, 0), + verbose_name="Конец рабочего дня", + ), + ), + ( + "expected_hours", + models.FloatField(default=9, verbose_name="Норма часов"), + ), + ( + "workdays", + models.JSONField( + blank=True, + default=list, + verbose_name="Рабочие дни", + ), + ), + ( + "date_overrides", + models.JSONField( + blank=True, + default=list, + verbose_name="Календарные исключения", + ), + ), + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Создано"), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True, verbose_name="Обновлено"), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="updated_standard_work_schedules", + to=settings.AUTH_USER_MODEL, + verbose_name="Изменил", + ), + ), + ], + options={ + "verbose_name": "Стандартный график работы", + "verbose_name_plural": "Стандартный график работы", + }, + ), + ] diff --git a/backend/attendance/models.py b/backend/attendance/models.py index 7df9092c..a1e5f754 100644 --- a/backend/attendance/models.py +++ b/backend/attendance/models.py @@ -110,6 +110,53 @@ class EmployeeWorkSchedule(models.Model): } +class StandardWorkSchedule(models.Model): + DEFAULT_WORKDAYS = EmployeeWorkSchedule.DEFAULT_WORKDAYS + + singleton = models.BooleanField(default=True, unique=True, editable=False) + start_time = models.TimeField("Начало рабочего дня", default=time(8, 0)) + end_time = models.TimeField("Конец рабочего дня", default=time(17, 0)) + expected_hours = models.FloatField("Норма часов", default=9) + workdays = models.JSONField("Рабочие дни", default=list, blank=True) + date_overrides = models.JSONField( + "Календарные исключения", + default=list, + blank=True, + ) + updated_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="updated_standard_work_schedules", + verbose_name="Изменил", + ) + created_at = models.DateTimeField("Создано", auto_now_add=True) + updated_at = models.DateTimeField("Обновлено", auto_now=True) + + class Meta: + verbose_name = "Стандартный график работы" + verbose_name_plural = "Стандартный график работы" + + def save(self, *args, **kwargs): + self.singleton = True + if not self.workdays: + self.workdays = list(self.DEFAULT_WORKDAYS) + super().save(*args, **kwargs) + + def __str__(self) -> str: + return f"{self.start_time} - {self.end_time}" + + def to_logstorm_payload(self) -> dict: + return { + "start_time": self.start_time.strftime("%H:%M"), + "end_time": self.end_time.strftime("%H:%M"), + "expected_hours": self.expected_hours, + "workdays": list(self.workdays or self.DEFAULT_WORKDAYS), + "date_overrides": list(self.date_overrides or []), + } + + class AttendanceRecord(models.Model): analysis_run = models.ForeignKey( AttendanceAnalysisRun, diff --git a/backend/attendance/services.py b/backend/attendance/services.py index be1f2af5..3a285a0f 100644 --- a/backend/attendance/services.py +++ b/backend/attendance/services.py @@ -1,17 +1,24 @@ from __future__ import annotations +from io import BytesIO from dataclasses import dataclass from datetime import date from calendar import monthrange from typing import Any +from django.conf import settings from django.db import transaction from django.utils.dateparse import parse_date +from openpyxl import Workbook +from openpyxl.comments import Comment +from openpyxl.styles import Alignment, Font, PatternFill, Side, Border +from openpyxl.utils import get_column_letter from attendance.models import ( AttendanceAnalysisRun, AttendanceRecord, EmployeeWorkSchedule, + StandardWorkSchedule, ) from employees.constants import ( ACTION_DISMISSED, @@ -49,6 +56,55 @@ DAYS_RU = { 6: "Вс", } +MATRIX_STATUS_LABELS = { + "empty": "Нет записи", + "technical": "Техсбой", + "underwork": "Недоработка", + "late": "Опоздание", + "overtime": "Переработка", + "absent": "Отсутствие", + "non_working": "Нерабочий", + "normal": "Норма", +} + +MATRIX_STATUS_SHORT_LABELS = { + "empty": "", + "technical": "Тех", + "underwork": "НД", + "late": "ОП", + "overtime": "ПР", + "absent": "Н", + "non_working": "Нер", + "normal": "OK", +} + +PERSONNEL_STATUS_SHORT_LABELS = { + ACTION_ON_LEAVE: "ОТП", + ACTION_ON_SICK_LEAVE: "БЛ", + ACTION_ON_DAY_OFF: "ОТГ", + ACTION_ON_MATERNITY: "ДЕКР", + ACTION_DISMISSED: "Вне штата", +} + +PERSONNEL_STATUS_LEGEND = { + ACTION_ON_LEAVE: "Отпуск", + ACTION_ON_SICK_LEAVE: "Больничный", + ACTION_ON_DAY_OFF: "Отгул", + ACTION_ON_MATERNITY: "Декрет", + ACTION_DISMISSED: "Уволен / вне штата", +} + +MATRIX_STATUS_FILLS = { + "empty": "F1F5F9", + "technical": "FEE2E2", + "underwork": "FEF3C7", + "late": "FEF3C7", + "absent": "FEF3C7", + "overtime": "DCFCE7", + "non_working": "E2E8F0", + "normal": "E0F2FE", +} + NON_WORKING_PERSONNEL_ACTIONS = { ACTION_ON_LEAVE, ACTION_ON_SICK_LEAVE, @@ -184,6 +240,17 @@ def get_default_work_schedule_payload() -> dict[str, Any]: } +def get_standard_work_schedule() -> StandardWorkSchedule | None: + return StandardWorkSchedule.objects.order_by("id").first() + + +def get_standard_work_schedule_payload() -> dict[str, Any]: + schedule = get_standard_work_schedule() + if schedule is None: + return get_default_work_schedule_payload() + return schedule.to_logstorm_payload() + + def build_monthly_attendance_matrix( *, employees, @@ -258,6 +325,235 @@ def build_monthly_attendance_matrix( } +def build_attendance_matrix_export_workbook( + *, + employees, + records, + period_start: date, + period_end: date, + record_comments: dict[int, list[dict[str, str]]] | None = None, +) -> bytes: + employees = list(employees) + records_by_month: dict[tuple[int, int], list[AttendanceRecord]] = {} + for record in records: + records_by_month.setdefault((record.date.year, record.date.month), []).append( + record + ) + + workbook = Workbook() + workbook.remove(workbook.active) + + for year, month in _month_pairs_between(period_start, period_end): + matrix = build_monthly_attendance_matrix( + employees=employees, + records=records_by_month.get((year, month), []), + year=year, + month=month, + ) + _append_attendance_matrix_sheet( + workbook, + matrix, + record_comments=record_comments or {}, + ) + + _append_attendance_matrix_legend_sheet(workbook) + + if not workbook.worksheets: + workbook.create_sheet("Посещаемость") + + stream = BytesIO() + workbook.save(stream) + return stream.getvalue() + + +def _month_pairs_between( + period_start: date, + period_end: date, +) -> list[tuple[int, int]]: + result = [] + year = period_start.year + month = period_start.month + while year < period_end.year or (year == period_end.year and month <= period_end.month): + result.append((year, month)) + month += 1 + if month > 12: + month = 1 + year += 1 + return result + + +def _append_attendance_matrix_sheet( + workbook: Workbook, + matrix: dict[str, Any], + *, + record_comments: dict[int, list[dict[str, str]]], +) -> None: + sheet = workbook.create_sheet(_safe_sheet_title(matrix["month_label"])) + header_fill = PatternFill("solid", fgColor="E2E8F0") + summary_fill = PatternFill("solid", fgColor="DBEAFE") + thin = Side(style="thin", color="CBD5E1") + border = Border(left=thin, right=thin, top=thin, bottom=thin) + + sheet.append(["Дата", "День", *[employee["name"] for employee in matrix["employees"]]]) + for cell in sheet[1]: + cell.font = Font(bold=True, color="0F172A") + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + cell.border = border + + for row in matrix["rows"]: + sheet.append( + [ + row["date"], + row["weekday"], + *[ + _format_matrix_export_cell(row["cells"][str(employee["id"])]) + for employee in matrix["employees"] + ], + ] + ) + excel_row = sheet.max_row + for index, excel_cell in enumerate(sheet[excel_row], start=1): + excel_cell.border = border + excel_cell.alignment = Alignment(vertical="top", wrap_text=True) + if index > 2: + matrix_cell = row["cells"][str(matrix["employees"][index - 3]["id"])] + status = matrix_cell["status"] + excel_cell.fill = PatternFill( + "solid", + fgColor=MATRIX_STATUS_FILLS.get(status, MATRIX_STATUS_FILLS["empty"]), + ) + note = _format_matrix_export_note( + row=row, + cell=matrix_cell, + comments=record_comments.get(matrix_cell.get("record_id") or 0, []), + ) + if note: + excel_cell.comment = Comment(note, "EUSRR") + record_id = matrix_cell.get("record_id") + if record_id: + excel_cell.hyperlink = _attendance_record_day_events_url(record_id) + excel_cell.font = Font(color="0563C1", underline="single") + + if matrix["summary"]: + sheet.append([]) + for summary_row in matrix["summary"]: + sheet.append( + [ + summary_row["label"], + "", + *[ + summary_row["values"].get(str(employee["id"]), 0) + for employee in matrix["employees"] + ], + ] + ) + excel_row = sheet.max_row + for cell in sheet[excel_row]: + cell.font = Font(bold=True, color="0F172A") + cell.fill = summary_fill + cell.border = border + + sheet.freeze_panes = "C2" + sheet.column_dimensions["A"].width = 14 + sheet.column_dimensions["B"].width = 10 + for column_index in range(3, len(matrix["employees"]) + 3): + sheet.column_dimensions[get_column_letter(column_index)].width = 18 + + +def _append_attendance_matrix_legend_sheet(workbook: Workbook) -> None: + sheet = workbook.create_sheet("Легенда") + header_fill = PatternFill("solid", fgColor="E2E8F0") + thin = Side(style="thin", color="CBD5E1") + border = Border(left=thin, right=thin, top=thin, bottom=thin) + rows = [ + ("Код", "Расшифровка"), + ("OK", "Норма"), + ("ОП", "Опоздание"), + ("НД", "Недоработка"), + ("ПР", "Переработка"), + ("Н", "Отсутствие"), + ("Вых", "Выходной по графику/календарю"), + ("Тех", "Технический сбой"), + ("ОТП", "Отпуск"), + ("БЛ", "Больничный"), + ("ОТГ", "Отгул"), + ("ДЕКР", "Декрет"), + ("Вне штата", "Уволен или еще не принят в выбранный день"), + ("+ Nч", "Зафиксирована работа в нерабочий день"), + ("К: N", "Количество комментариев EUSRR к записи"), + ("Ссылка", "Клик по ячейке записи открывает модал событий LogStorm в EUSRR"), + ] + for row in rows: + sheet.append(row) + for row_index, row in enumerate(sheet.iter_rows(), start=1): + for cell in row: + cell.border = border + cell.alignment = Alignment(vertical="top", wrap_text=True) + if row_index == 1: + cell.font = Font(bold=True, color="0F172A") + cell.fill = header_fill + sheet.column_dimensions["A"].width = 16 + sheet.column_dimensions["B"].width = 56 + + +def _attendance_record_day_events_url(record_id: int) -> str: + site_url = getattr(settings, "SITE_URL", "https://corp.robotail.pro").rstrip("/") + return f"{site_url}/attendance?record={record_id}&events=1" + + +def _safe_sheet_title(value: str) -> str: + forbidden = str.maketrans({char: " " for char in "[]:*?/\\\""}) + return value.translate(forbidden)[:31] or "Посещаемость" + + +def _format_matrix_export_cell(cell: dict[str, Any]) -> str: + if cell["status"] == "empty": + return "" + return str(cell.get("display_text") or cell.get("short_label") or "") + + +def _format_matrix_export_note( + *, + row: dict[str, Any], + cell: dict[str, Any], + comments: list[dict[str, str]], +) -> str: + if cell["status"] == "empty" and not comments: + return "" + + lines = [ + f"Дата: {row['date']} ({row['weekday']})", + f"Статус: {cell.get('primary_label') or MATRIX_STATUS_LABELS.get(cell['status'], cell['status'])}", + ] + details = [str(item) for item in cell.get("detail_lines", []) if item] + if details: + lines.extend(details) + + if comments: + lines.append("") + lines.append("Комментарии EUSRR:") + for comment in comments: + author = comment.get("author") or "Сотрудник" + created_at = comment.get("created_at") or "" + text = comment.get("text") or "" + prefix = f"- {author}" + if created_at: + prefix = f"{prefix}, {created_at}" + lines.append(f"{prefix}: {text}") + + return "\n".join(lines) + + +def _format_export_number(value: Any) -> str: + if value is None or value == "": + return "0" + numeric = float(value) + if numeric.is_integer(): + return str(int(numeric)) + return f"{numeric:.2f}".rstrip("0").rstrip(".") + + def resolve_employee_day_state( personnel_actions, record_date: date, @@ -506,10 +802,16 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]: "work_hours": None, "expected_hours": None, "status": "empty", + "short_label": "", + "display_text": "", + "primary_label": MATRIX_STATUS_LABELS["empty"], + "detail_lines": [], "issues": [], "is_workday": None, "effective_is_workday": None, "non_working_reason": "", + "is_manually_edited": False, + "manual_edited_at": None, "comments_count": 0, } @@ -519,13 +821,21 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]: *record.technical_issues, ] comments_count = getattr(record, "comments_count", 0) or 0 + status = _monthly_matrix_status(record) + presentation = _monthly_matrix_cell_presentation( + record=record, + status=status, + issues=issues, + comments_count=comments_count, + ) return { "record_id": record.id, "arrival_time": record.arrival_time, "departure_time": record.departure_time, "work_hours": record.work_hours, "expected_hours": record.expected_hours, - "status": _monthly_matrix_status(record), + "status": status, + **presentation, "issues": issues, "is_workday": record.is_workday, "effective_is_workday": record.effective_is_workday, @@ -538,10 +848,161 @@ def _monthly_matrix_cell(record: AttendanceRecord | None) -> dict[str, Any]: "is_absent": record.is_absent, "personnel_status": record.personnel_status, "personnel_status_label": record.personnel_status_label, + "is_manually_edited": record.is_manually_edited, + "manual_edited_at": record.manual_edited_at, "comments_count": comments_count, } +def _monthly_matrix_cell_presentation( + *, + record: AttendanceRecord, + status: str, + issues: list[Any], + comments_count: int, +) -> dict[str, Any]: + primary_label = _matrix_primary_label(record, status) + short_label = _matrix_short_label(record, status) + display_text = _matrix_display_text(record, status, short_label) + detail_lines = _matrix_detail_lines( + record=record, + status=status, + issues=issues, + comments_count=comments_count, + ) + return { + "short_label": short_label, + "display_text": display_text, + "primary_label": primary_label, + "detail_lines": detail_lines, + } + + +def _matrix_primary_label(record: AttendanceRecord, status: str) -> str: + if record.personnel_status != PERSONNEL_STATUS_NORMAL: + return ( + PERSONNEL_STATUS_LEGEND.get(record.personnel_status) + or record.personnel_status_label + or "Кадровое событие" + ) + if not record.effective_is_workday: + return _record_non_working_reason(record) or MATRIX_STATUS_LABELS["non_working"] + return MATRIX_STATUS_LABELS.get(status, status) + + +def _matrix_short_label(record: AttendanceRecord, status: str) -> str: + if record.personnel_status != PERSONNEL_STATUS_NORMAL: + return ( + PERSONNEL_STATUS_SHORT_LABELS.get(record.personnel_status) + or record.personnel_status_label + or MATRIX_STATUS_SHORT_LABELS.get(status, status) + ) + if not record.effective_is_workday: + if not record.is_workday: + return "Вых" + return MATRIX_STATUS_SHORT_LABELS["non_working"] + return MATRIX_STATUS_SHORT_LABELS.get(status, status) + + +def _matrix_display_text( + record: AttendanceRecord, + status: str, + short_label: str, +) -> str: + if not record.effective_is_workday: + if record.work_hours: + return f"{short_label} + {_format_export_number(record.work_hours)}ч" + return short_label + + if status == "normal": + time_part = f"{record.arrival_time or '-'}/{record.departure_time or '-'}" + return f"{time_part} · {_format_export_number(record.work_hours)}ч" + time_part = f"{record.arrival_time or '-'}/{record.departure_time or '-'}" + if status == "late": + suffix = f" {record.late_minutes}м" if record.late_minutes else "" + return f"{short_label}{suffix} · {time_part}" + if status == "underwork": + suffix = ( + f" {_format_export_number(record.underwork_hours)}ч" + if record.underwork_hours + else "" + ) + return f"{short_label}{suffix} · {time_part}" + if status == "overtime": + suffix = ( + f" +{_format_export_number(record.overtime_hours)}ч" + if record.overtime_hours + else "" + ) + return f"{short_label}{suffix} · {time_part}" + if status == "technical": + issue_count = len(record.technical_issues) + label = f"{short_label} ({issue_count})" if issue_count else short_label + return f"{label} · {time_part}" if record.arrival_time or record.departure_time else label + return short_label + + +def _matrix_detail_lines( + *, + record: AttendanceRecord, + status: str, + issues: list[Any], + comments_count: int, +) -> list[str]: + lines = [ + f"Приход: {record.arrival_time or '-'}", + f"Уход: {record.departure_time or '-'}", + ( + "Часы: " + f"{_format_export_number(record.work_hours)} / " + f"{_format_export_number(record.expected_hours)}" + ), + f"Рабочий день: {'да' if record.effective_is_workday else 'нет'}", + ] + if record.personnel_status != PERSONNEL_STATUS_NORMAL: + lines.append(f"Кадровое событие: {_matrix_primary_label(record, status)}") + elif not record.effective_is_workday: + reason = _record_non_working_reason(record) + if reason: + lines.append(f"Причина нерабочего дня: {reason}") + if record.is_manually_edited: + lines.append("Источник: ручная корректировка EUSRR") + + if record.is_late and record.late_minutes: + lines.append(f"Опоздание: {record.late_minutes} мин.") + if record.is_early_leave and record.early_leave_minutes: + lines.append(f"Ранний уход: {record.early_leave_minutes} мин.") + if status != "absent" and record.is_underwork and record.underwork_hours: + lines.append(f"Недоработка: {_format_export_number(record.underwork_hours)} ч.") + if record.is_overtime and record.overtime_hours: + lines.append(f"Переработка: {_format_export_number(record.overtime_hours)} ч.") + if record.is_absent: + lines.append("Отсутствие: да") + if record.technical_issues: + lines.append( + "Технические проблемы: " + + "; ".join(str(issue) for issue in record.technical_issues) + ) + employee_issues = [ + str(issue) + for issue in record.employee_issues + if not (status == "absent" and "underwork" in str(issue).lower()) + ] + if employee_issues: + lines.append("Проблемы сотрудника: " + "; ".join(employee_issues)) + extra_issues = [ + str(issue) + for issue in issues + if issue not in record.technical_issues and issue not in record.employee_issues + and not (status == "absent" and "underwork" in str(issue).lower()) + ] + if extra_issues: + lines.append("Статусы анализа: " + "; ".join(extra_issues)) + if comments_count: + lines.append(f"Комментарии: {comments_count}") + return lines + + def _monthly_matrix_status(record: AttendanceRecord) -> str: if not record.effective_is_workday: if record.is_overtime: @@ -549,6 +1010,8 @@ def _monthly_matrix_status(record: AttendanceRecord) -> str: return "non_working" if record.technical_issues: return "technical" + if record.is_absent and not record.arrival_time and not record.departure_time: + return "absent" if record.is_underwork: return "underwork" if record.is_late: diff --git a/backend/common/logstorm_client.py b/backend/common/logstorm_client.py index d6946729..35f1d1e6 100644 --- a/backend/common/logstorm_client.py +++ b/backend/common/logstorm_client.py @@ -11,6 +11,10 @@ from django.conf import settings class LogStormClientError(RuntimeError): """Raised when LogStorm cannot process an attendance request.""" + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + @dataclass(frozen=True) class LogStormConfig: @@ -50,7 +54,8 @@ class LogStormClient: if not response.ok: raise LogStormClientError( f"LogStorm API returned HTTP {response.status_code}: " - f"{response.text}" + f"{response.text}", + status_code=response.status_code, ) try: return response.json() @@ -73,13 +78,59 @@ class LogStormClient: if not response.ok: raise LogStormClientError( f"LogStorm API returned HTTP {response.status_code}: " - f"{response.text}" + f"{response.text}", + status_code=response.status_code, ) try: return response.json() except ValueError as exc: raise LogStormClientError("LogStorm API returned invalid JSON") from exc + def get_attendance_day_events( + self, + *, + employee_id: str, + record_date: date | str, + ) -> list[dict[str, Any]]: + response = self.session.get( + self._url("/attendance/events/day/"), + params={ + "employee_id": str(employee_id), + "date": str(record_date), + }, + headers=self._headers(), + timeout=self.config.timeout, + ) + if not response.ok: + raise LogStormClientError( + f"LogStorm API returned HTTP {response.status_code}: " + f"{response.text}", + status_code=response.status_code, + ) + try: + payload = response.json() + except ValueError as exc: + raise LogStormClientError("LogStorm API returned invalid JSON") from exc + if not isinstance(payload, list): + raise LogStormClientError("LogStorm API returned invalid events payload") + return payload + + def get_attendance_event_photo(self, event_key: str) -> requests.Response: + headers = self._headers() + headers["Accept"] = "image/*" + response = self.session.get( + self._url(f"/attendance/events/photos/{event_key}/"), + headers=headers, + timeout=self.config.timeout, + ) + if not response.ok: + raise LogStormClientError( + f"LogStorm API returned HTTP {response.status_code}: " + f"{response.text}", + status_code=response.status_code, + ) + return response + def health(self) -> bool: try: response = self.session.get( diff --git a/backend/tests/api/v1/attendance/test_attendance_storage.py b/backend/tests/api/v1/attendance/test_attendance_storage.py index 675377c8..d3776771 100644 --- a/backend/tests/api/v1/attendance/test_attendance_storage.py +++ b/backend/tests/api/v1/attendance/test_attendance_storage.py @@ -1,15 +1,19 @@ from unittest.mock import patch +from io import BytesIO from datetime import datetime import pytest from django.urls import reverse from django.utils import timezone +from openpyxl import load_workbook from attendance.models import ( AttendanceAnalysisRun, AttendanceRecord, EmployeeWorkSchedule, + StandardWorkSchedule, ) +from common.logstorm_client import LogStormClientError from employees.constants import ACTION_ON_LEAVE, ACTION_RETURNED_FROM_LEAVE from employees.models import EmployeeAction @@ -28,14 +32,33 @@ def _monthly_matrix_url(): return reverse("api:v1:attendance-monthly-matrix") +def _monthly_matrix_export_url(): + return reverse("api:v1:attendance-monthly-matrix-export") + + def _work_schedule_url(employee_id): return reverse("api:v1:attendance-work-schedule", args=[employee_id]) +def _standard_work_schedule_url(): + return reverse("api:v1:attendance-standard-work-schedule") + + def _record_detail_url(record_id): return reverse("api:v1:attendance-record-detail", args=[record_id]) +def _record_day_events_url(record_id): + return reverse("api:v1:attendance-record-day-events", args=[record_id]) + + +def _record_day_event_photo_url(record_id, event_key): + return reverse( + "api:v1:attendance-record-day-event-photo", + args=[record_id, event_key], + ) + + def _record_comments_url(record_id): return reverse("api:v1:attendance-record-comments", args=[record_id]) @@ -107,6 +130,11 @@ def _move_auto_hired_before_test_period(employee): employee.actions.filter(action="hired").update(date=_aware_datetime(2026, 1, 1)) +class _PhotoResponse: + content = b"fake-image" + headers = {"Content-Type": "image/jpeg"} + + def test_analyze_saves_run_and_records(auth_client_factory, user_factory): staff = user_factory(staff=True) employee = user_factory(first_name="Ivan", last_name="Petrov") @@ -185,6 +213,82 @@ def test_staff_can_save_employee_work_schedule( assert schedule.expected_hours == 8 +def test_staff_can_read_default_standard_work_schedule( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.get(_standard_work_schedule_url()) + + assert response.status_code == 200 + assert response.data["id"] is None + assert response.data["is_default"] is True + assert response.data["start_time"] == "08:00" + assert response.data["end_time"] == "17:00" + assert response.data["expected_hours"] == 9 + assert response.data["workdays"] == EmployeeWorkSchedule.DEFAULT_WORKDAYS + + +def test_staff_can_save_standard_work_schedule( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + client = auth_client_factory(staff) + + response = client.patch( + _standard_work_schedule_url(), + { + "start_time": "07:30", + "end_time": "16:30", + "expected_hours": 8, + "workdays": ["Monday", "Tuesday", "Wednesday", "Thursday"], + }, + format="json", + ) + + assert response.status_code == 200 + assert response.data["is_default"] is False + assert response.data["start_time"] == "07:30:00" + assert response.data["end_time"] == "16:30:00" + assert response.data["workdays"] == [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + ] + + schedule = StandardWorkSchedule.objects.get() + assert schedule.updated_by == staff + assert schedule.expected_hours == 8 + + get_response = client.get(_standard_work_schedule_url()) + assert get_response.status_code == 200 + assert get_response.data["id"] == schedule.id + assert get_response.data["start_time"] == "07:30:00" + + +def test_non_staff_cannot_manage_standard_work_schedule( + auth_client_factory, + user_factory, +): + user = user_factory() + client = auth_client_factory(user) + + get_response = client.get(_standard_work_schedule_url()) + patch_response = client.patch( + _standard_work_schedule_url(), + {"start_time": "09:00"}, + format="json", + ) + + assert get_response.status_code == 403 + assert patch_response.status_code == 403 + assert not StandardWorkSchedule.objects.exists() + + def test_non_staff_cannot_save_other_employee_work_schedule( auth_client_factory, user_factory, @@ -238,6 +342,40 @@ def test_analyze_uses_saved_work_schedule_when_payload_omits_schedule( assert run.schedule_payload == schedule +def test_analyze_uses_standard_work_schedule_when_payload_omits_schedule( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + StandardWorkSchedule.objects.create( + start_time="07:00", + end_time="16:00", + expected_hours=8, + workdays=["Monday", "Tuesday", "Wednesday", "Thursday"], + ) + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ) as analyze: + response = client.post(_analyze_url(), _payload(employee.id), format="json") + + assert response.status_code == 200 + schedule = analyze.call_args.kwargs["schedule"] + assert schedule == { + "start_time": "07:00", + "end_time": "16:00", + "expected_hours": 8, + "workdays": ["Monday", "Tuesday", "Wednesday", "Thursday"], + "date_overrides": [], + } + + run = AttendanceAnalysisRun.objects.get() + assert run.schedule_payload == schedule + + def test_analyze_updates_existing_daily_record(auth_client_factory, user_factory): staff = user_factory(staff=True) employee = user_factory() @@ -312,6 +450,198 @@ def test_records_endpoint_returns_saved_records(auth_client_factory, user_factor assert {item["date"] for item in results} == {"2026-04-20", "2026-04-21"} +def test_user_can_read_own_attendance_record_detail( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + staff = user_factory(staff=True) + staff_client = auth_client_factory(staff) + client = auth_client_factory(user) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + staff_client.post(_analyze_url(), _payload(user.id), format="json") + + record = AttendanceRecord.objects.get(employee=user, date="2026-04-20") + + response = client.get(_record_detail_url(record.id)) + + assert response.status_code == 200 + assert response.json()["id"] == record.id + assert response.json()["employee"] == user.id + + +def test_staff_can_load_attendance_day_events( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + with patch( + "api.v1.attendance.views.LogStormClient.get_attendance_day_events", + return_value=[ + { + "event_key": "abc", + "time": "2026-04-20T09:15:00+03:00", + "time_label": "09:15:00", + "caption": "Успешный вход", + "device": "door-1", + "device_name": "Вход", + "serial_no": 10, + "has_photo": True, + "photo_url": "/attendance/events/photos/abc/", + }, + { + "event_key": "def", + "time": "2026-04-20T18:00:00+03:00", + "time_label": "18:00:00", + "caption": "Успешный выход", + "device": "door-2", + "device_name": "Выход", + "serial_no": 11, + "has_photo": False, + "photo_url": None, + }, + ], + ) as logstorm_events: + response = client.get(_record_day_events_url(record.id)) + + assert response.status_code == 200 + logstorm_events.assert_called_once_with( + employee_id=str(employee.id), + record_date=record.date, + ) + events = response.json() + assert [event["caption"] for event in events] == ["Успешный вход", "Успешный выход"] + assert events[0]["photo_url"].endswith( + f"/api/v1/attendance/records/{record.id}/day-events/abc/photo/" + ) + assert events[1]["photo_url"] is None + + +def test_user_cannot_load_other_employee_attendance_day_events( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + user = user_factory(staff=False) + employee = user_factory() + staff_client = auth_client_factory(staff) + client = auth_client_factory(user) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + staff_client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + + response = client.get(_record_day_events_url(record.id)) + + assert response.status_code == 404 + + +def test_attendance_day_events_logstorm_error_returns_502( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + with patch( + "api.v1.attendance.views.LogStormClient.get_attendance_day_events", + side_effect=LogStormClientError("boom"), + ): + response = client.get(_record_day_events_url(record.id)) + + assert response.status_code == 502 + assert response.json()["error"] == "logstorm_unavailable" + + +def test_staff_can_proxy_attendance_day_event_photo( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + with ( + patch( + "api.v1.attendance.views.LogStormClient.get_attendance_day_events", + return_value=[{"event_key": "abc", "has_photo": True}], + ), + patch( + "api.v1.attendance.views.LogStormClient.get_attendance_event_photo", + return_value=_PhotoResponse(), + ) as logstorm_photo, + ): + response = client.get(_record_day_event_photo_url(record.id, "abc")) + + assert response.status_code == 200 + assert response.content == b"fake-image" + assert response["Content-Type"] == "image/jpeg" + logstorm_photo.assert_called_once_with("abc") + + +def test_attendance_day_event_photo_rejects_event_outside_record_day( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + with ( + patch( + "api.v1.attendance.views.LogStormClient.get_attendance_day_events", + return_value=[{"event_key": "other", "has_photo": True}], + ), + patch( + "api.v1.attendance.views.LogStormClient.get_attendance_event_photo", + ) as logstorm_photo, + ): + response = client.get(_record_day_event_photo_url(record.id, "abc")) + + assert response.status_code == 404 + logstorm_photo.assert_not_called() + + def test_monthly_matrix_endpoint_returns_excel_like_month( auth_client_factory, user_factory, @@ -348,6 +678,9 @@ def test_monthly_matrix_endpoint_returns_excel_like_month( assert employee_cell["arrival_time"] == "09:15" assert employee_cell["departure_time"] == "18:00" assert employee_cell["status"] == "underwork" + assert employee_cell["short_label"] == "НД" + assert employee_cell["display_text"] == "НД 0.25ч · 09:15/18:00" + assert "Недоработка: 0.25 ч." in employee_cell["detail_lines"] summary_by_key = {item["key"]: item for item in payload["summary"]} assert summary_by_key["late_days"]["values"][str(employee.id)] == 1 @@ -355,6 +688,222 @@ def test_monthly_matrix_endpoint_returns_excel_like_month( assert summary_by_key["worked_hours"]["values"][str(employee.id)] == 8.75 +def test_staff_can_export_monthly_matrix_xlsx_from_saved_records( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory(first_name="Ivan", last_name="Petrov") + other = user_factory(first_name="Anna", last_name="Sidorova") + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + client.post(_analyze_url(), _payload(other.id), format="json") + + record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20") + client.post( + _record_comments_url(record.id), + {"text": "Проверить причину опоздания"}, + format="json", + ) + + with patch("api.v1.attendance.views.analyze_employee_attendance") as analyze: + response = client.get( + _monthly_matrix_export_url(), + { + "employee_ids": f"{employee.id},{other.id}", + "period_start": "2026-04-20", + "period_end": "2026-05-05", + }, + ) + + assert response.status_code == 200 + assert response["Content-Type"] == ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + assert response["Content-Disposition"] == ( + 'attachment; filename="attendance-2026-04-20_2026-05-05.xlsx"' + ) + analyze.assert_not_called() + + workbook = load_workbook(BytesIO(response.content)) + assert workbook.sheetnames == ["Апрель 2026", "Май 2026", "Легенда"] + + april = workbook["Апрель 2026"] + assert april["A1"].value == "Дата" + assert april["B1"].value == "День" + assert april["C1"].value == "Petrov Ivan" + assert april["D1"].value == "Sidorova Anna" + assert april["A21"].value == "2026-04-20" + assert april["C21"].value == "НД 0.25ч · 09:15/18:00" + assert april["C21"].hyperlink.target.endswith( + f"/attendance?record={record.id}&events=1" + ) + assert "Недоработка: 0.25 ч." in april["C21"].comment.text + assert "Проверить причину опоздания" in april["C21"].comment.text + assert april["C33"].value == 2 + assert april["C35"].value == 8.75 + assert workbook["Легенда"]["A2"].value == "OK" + + +def test_monthly_matrix_prioritizes_absence_when_no_times_and_no_personnel_reason( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + result = { + "records": [ + { + "date": "2026-04-20", + "employee_id": str(employee.id), + "display_name": "Absent User", + "arrival_time": None, + "departure_time": None, + "work_hours": 0, + "expected_hours": 9, + "is_workday": True, + "is_absent": True, + "is_underwork": True, + "underwork_hours": 9, + "statuses": ["absence", "underwork"], + "employee_issues": ["absence", "underwork"], + "technical_issues": [], + } + ] + } + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=result, + ): + response = client.post( + _analyze_url(), + _payload(employee.id, period_start="2026-04-20", period_end="2026-04-20"), + format="json", + ) + assert response.status_code == 200 + + 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-20" + ) + matrix_cell = matrix_row["cells"][str(employee.id)] + assert matrix_cell["status"] == "absent" + assert matrix_cell["display_text"] == "Н" + assert matrix_cell["primary_label"] == "Отсутствие" + + +def test_non_staff_cannot_export_monthly_matrix_xlsx( + auth_client_factory, + user_factory, +): + user = user_factory(staff=False) + client = auth_client_factory(user) + + response = client.get( + _monthly_matrix_export_url(), + { + "employee_ids": str(user.id), + "period_start": "2026-04-20", + "period_end": "2026-04-21", + }, + ) + + assert response.status_code == 403 + + +def test_monthly_matrix_xlsx_export_rejects_invalid_period( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory() + client = auth_client_factory(staff) + + response = client.get( + _monthly_matrix_export_url(), + { + "employee_ids": str(employee.id), + "period_start": "2026-04-21", + "period_end": "2026-04-20", + }, + ) + + assert response.status_code == 400 + + +def test_monthly_matrix_xlsx_export_includes_empty_month_without_records( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory(first_name="Empty", last_name="Month") + client = auth_client_factory(staff) + + response = client.get( + _monthly_matrix_export_url(), + { + "employee_ids": str(employee.id), + "period_start": "2026-04-20", + "period_end": "2026-04-21", + }, + ) + + assert response.status_code == 200 + workbook = load_workbook(BytesIO(response.content)) + april = workbook["Апрель 2026"] + assert april["C1"].value == "Month Empty" + assert april["C21"].value is None + + +def test_monthly_matrix_xlsx_export_keeps_full_month_but_limits_records_to_period( + auth_client_factory, + user_factory, +): + staff = user_factory(staff=True) + employee = user_factory(first_name="Ivan", last_name="Petrov") + client = auth_client_factory(staff) + + with patch( + "api.v1.attendance.views.analyze_employee_attendance", + return_value=_logstorm_result(), + ): + client.post(_analyze_url(), _payload(employee.id), format="json") + + response = client.get( + _monthly_matrix_export_url(), + { + "employee_ids": str(employee.id), + "period_start": "2026-04-21", + "period_end": "2026-04-21", + }, + ) + + assert response.status_code == 200 + workbook = load_workbook(BytesIO(response.content)) + april = workbook["Апрель 2026"] + assert april.max_row == 39 + assert april["A20"].value == "2026-04-19" + assert april["A21"].value == "2026-04-20" + assert april["A22"].value == "2026-04-21" + assert april["C21"].value is None + assert april["C22"].value == "Н" + assert april["C33"].value == 1 + assert april["C35"].value == 0 + + def test_user_can_read_own_attendance_records(auth_client_factory, user_factory): user = user_factory(staff=False) other = user_factory() @@ -521,6 +1070,21 @@ def test_personnel_non_working_state_is_saved_and_suppresses_absence( 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["short_label"] == "ОТП" + assert matrix_cell["display_text"] == "ОТП" + assert matrix_cell["primary_label"] == "Отпуск" + def test_calendar_non_working_day_suppresses_absence_without_personnel_state( auth_client_factory, @@ -594,6 +1158,8 @@ def test_calendar_non_working_day_suppresses_absence_without_personnel_state( ) matrix_cell = matrix_row["cells"][str(employee.id)] assert matrix_cell["status"] == "non_working" + assert matrix_cell["short_label"] == "Вых" + assert matrix_cell["primary_label"] == "Выходной по графику/календарю" assert matrix_cell["issues"] == [] diff --git a/backend/tests/integration/test_logstorm_attendance_api.py b/backend/tests/integration/test_logstorm_attendance_api.py index ca1c833a..8ba17bf4 100644 --- a/backend/tests/integration/test_logstorm_attendance_api.py +++ b/backend/tests/integration/test_logstorm_attendance_api.py @@ -51,7 +51,7 @@ def test_logstorm_attendance_api_rejects_other_employee_for_user( assert response.status_code == 403 -def test_logstorm_attendance_api_calls_builder_without_schedule( +def test_logstorm_attendance_api_uses_default_schedule_when_schedule_omitted( auth_client_factory, user_factory, ): @@ -69,7 +69,13 @@ def test_logstorm_attendance_api_calls_builder_without_schedule( assert response.json() == {"records": []} kwargs = analyze.call_args.kwargs assert kwargs["employee"] == employee - assert kwargs["schedule"] is None + assert kwargs["schedule"] == { + "start_time": "08:00", + "end_time": "17:00", + "expected_hours": 9, + "workdays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], + "date_overrides": [], + } def test_logstorm_attendance_api_passes_schedule( diff --git a/backend/tests/integration/test_logstorm_client.py b/backend/tests/integration/test_logstorm_client.py index c15e972f..9b6e5bc8 100644 --- a/backend/tests/integration/test_logstorm_client.py +++ b/backend/tests/integration/test_logstorm_client.py @@ -9,10 +9,12 @@ from common.logstorm_client import ( class FakeResponse: - def __init__(self, status_code=200, payload=None, text=""): + def __init__(self, status_code=200, payload=None, text="", content=b""): self.status_code = status_code self._payload = payload self.text = text + self.content = content + self.headers = {"Content-Type": "image/jpeg"} self.ok = 200 <= status_code < 400 def json(self): @@ -116,3 +118,53 @@ def test_health_returns_boolean(): assert ok_client.health() is True assert bad_client.health() is False + + +def test_get_attendance_day_events_requests_employee_date(): + session = FakeSession(FakeResponse(payload=[])) + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000", token="secret"), + session=session, + ) + + result = client.get_attendance_day_events( + employee_id="100", + record_date="2026-04-20", + ) + + assert result == [] + method, url, kwargs = session.requests[0] + assert method == "GET" + assert url == "http://logstorm:8000/attendance/events/day/" + assert kwargs["params"] == {"employee_id": "100", "date": "2026-04-20"} + assert kwargs["headers"]["Authorization"] == "Bearer secret" + + +def test_get_attendance_day_events_rejects_invalid_payload(): + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000"), + session=FakeSession(FakeResponse(payload={"events": []})), + ) + + with pytest.raises(LogStormClientError, match="invalid events payload"): + client.get_attendance_day_events( + employee_id="100", + record_date="2026-04-20", + ) + + +def test_get_attendance_event_photo_returns_response(): + session = FakeSession(FakeResponse(content=b"image")) + client = LogStormClient( + LogStormConfig(base_url="http://logstorm:8000", token="secret"), + session=session, + ) + + response = client.get_attendance_event_photo("abc") + + assert response.content == b"image" + method, url, kwargs = session.requests[0] + assert method == "GET" + assert url == "http://logstorm:8000/attendance/events/photos/abc/" + assert kwargs["headers"]["Accept"] == "image/*" + assert kwargs["headers"]["Authorization"] == "Bearer secret" diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts index 7f33cf66..038e377e 100644 --- a/frontend/src/app/api/[...path]/route.ts +++ b/frontend/src/app/api/[...path]/route.ts @@ -85,25 +85,25 @@ async function proxyRequest(request: NextRequest, method: string) { }); } - // response.text() уже декодирует gzip/br/deflate. - // Удаляем encoding-заголовки, иначе браузер попытается - // распаковать повторно → ERR_CONTENT_DECODING_FAILED. + // arrayBuffer сохраняет бинарные ответы (.xlsx, изображения) и работает + // для JSON/текста без потери заголовков. responseHeaders.delete('content-encoding'); responseHeaders.delete('content-length'); responseHeaders.delete('transfer-encoding'); // Возвращаем ответ от backend - const responseBody = await response.text(); + const responseBody = await response.arrayBuffer(); return new NextResponse(responseBody, { status: response.status, statusText: response.statusText, headers: responseHeaders, }); - } catch (error: any) { - console.error('[Proxy Error]', error.message); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error('[Proxy Error]', message); return NextResponse.json( - { error: 'Ошибка при обращении к backend', message: error.message }, + { error: 'Ошибка при обращении к backend', message }, { status: 502 } ); } diff --git a/frontend/src/app/attendance/page.tsx b/frontend/src/app/attendance/page.tsx index 344252ac..ce7b6d55 100644 --- a/frontend/src/app/attendance/page.tsx +++ b/frontend/src/app/attendance/page.tsx @@ -1,20 +1,31 @@ "use client"; -import { Fragment, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AlertTriangle, CalendarCheck, Check, - ChevronDown, ChevronLeft, ChevronRight, + Download, Loader2, + MessageSquare, RefreshCw, + Save, Search, } from "lucide-react"; import { AppShell } from "@/components/AppShell"; +import { + AttendanceDayEventsModal, + type AttendanceDayEventsPreview, +} from "@/components/attendance/AttendanceDayEventsModal"; +import { + AttendanceRecordCommentsModal, + type AttendanceRecordCommentsPreview, +} from "@/components/attendance/AttendanceRecordCommentsModal"; import { Modal } from "@/components/ui"; +import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; import { loadAllPages, displayUserName } from "@/lib/shared"; import { resolveMediaUrl } from "@/lib/url"; @@ -24,6 +35,7 @@ import type { MonthlyAttendanceMatrixEmployee, MonthlyAttendanceMatrixRow, AttendanceSchedulePayload, + AttendanceRecord, } from "@/lib/api/attendance"; import type { User } from "@/types/api"; @@ -58,6 +70,10 @@ function toDateInputValue(date: Date) { return `${year}-${month}-${day}`; } +function toTimeInputValue(value: string | undefined) { + return (value || "").slice(0, 5); +} + function getPeriodStart(preset: Exclude) { const date = new Date(); if (preset === "week") date.setDate(date.getDate() - 6); @@ -125,6 +141,11 @@ function matrixCellTone(status: string) { return "bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--surface-secondary)]"; } +function matrixCellSourceTone(cell: MonthlyAttendanceMatrixCell) { + if (!cell.is_manually_edited) return ""; + return "ring-1 ring-inset ring-violet-400/70 shadow-[inset_0_0_0_9999px_rgba(139,92,246,0.10)]"; +} + function matrixStatusLabel(status: string) { const labels: Record = { empty: "Нет записи", @@ -157,6 +178,7 @@ function formatAttendanceIssueLabel(label: string) { } function matrixCellIssueLabels(cell: MonthlyAttendanceMatrixCell) { + if (cell.status === "absent") return ["absence"]; const result = [...cell.issues.map(String)]; if (cell.effective_is_workday !== false && cell.is_workday !== false) { if (cell.is_absent) result.push("absence"); @@ -168,6 +190,90 @@ function matrixCellIssueLabels(cell: MonthlyAttendanceMatrixCell) { return Array.from(new Set(result)); } +function attendanceRecordIssueLabels(record: AttendanceRecord) { + const result = [ + ...(record.employee_issues || []), + ...(record.technical_issues || []), + ].map(String); + if (record.is_absent) result.push("absence"); + if (record.is_late) result.push("late"); + if (record.is_early_leave) result.push("early_leave"); + if (record.is_underwork) result.push("underwork"); + if (record.is_overtime) result.push("overtime"); + return Array.from(new Set(result)).map(formatAttendanceIssueLabel); +} + +function matrixCellDisplay(cell: MonthlyAttendanceMatrixCell) { + if (cell.display_text) return cell.display_text; + if (cell.short_label) return cell.short_label; + if (cell.status === "empty") return ""; + return `${formatTime(cell.arrival_time)}/${formatTime(cell.departure_time)}`; +} + +function attendanceRecordStatusLabel(record: AttendanceRecord) { + if (record.technical_issues?.length) return "Техсбой"; + if (record.personnel_status_label) return record.personnel_status_label; + if (record.is_absent) return "Отсутствие"; + if (record.is_underwork) return "Недоработка"; + if (record.is_late) return "Опоздание"; + if (record.is_overtime) return "Переработка"; + if (record.effective_is_workday === false || record.is_workday === false) { + return "Нерабочий"; + } + return "Норма"; +} + +function attendanceRecordDisplay(record: AttendanceRecord) { + const arrival = formatTime(record.arrival_time); + const departure = formatTime(record.departure_time); + if (arrival === "-" && departure === "-") { + if (record.is_absent) return "Отсутствие"; + return record.non_working_reason || "Нет проходов"; + } + return `${arrival}/${departure} · ${formatHours(record.work_hours)}ч`; +} + +function attendanceRecordDetails(record: AttendanceRecord) { + const lines = [ + `Статус: ${attendanceRecordStatusLabel(record)}`, + `Приход: ${formatTime(record.arrival_time)}`, + `Уход: ${formatTime(record.departure_time)}`, + `Часы: ${formatHours(record.work_hours)} / ${formatHours(record.expected_hours)}`, + ]; + if (record.late_minutes) lines.push(`Опоздание: ${record.late_minutes} мин.`); + if (record.early_leave_minutes) { + lines.push(`Ранний уход: ${record.early_leave_minutes} мин.`); + } + if (record.underwork_hours) { + lines.push(`Недоработка: ${formatHours(record.underwork_hours)} ч.`); + } + if (record.overtime_hours) { + lines.push(`Переработка: ${formatHours(record.overtime_hours)} ч.`); + } + if (record.non_working_reason) { + lines.push(`Причина: ${record.non_working_reason}`); + } + if (record.comments_count) lines.push(`Комментарии: ${record.comments_count}`); + return lines; +} + +function weekdayLabelFromDate(value?: string) { + if (!value) return ""; + const date = new Date(`${value}T00:00:00`); + if (Number.isNaN(date.getTime())) return ""; + return DAYS_RU_FROM_INDEX[date.getDay()] || ""; +} + +const DAYS_RU_FROM_INDEX: Record = { + 0: "Вс", + 1: "Пн", + 2: "Вт", + 3: "Ср", + 4: "Чт", + 5: "Пт", + 6: "Сб", +}; + function calculateMatrixEmployeesPerPage(width: number) { const availableWidth = Math.max( 0, @@ -180,21 +286,24 @@ function calculateMatrixEmployeesPerPage(width: number) { } export default function AttendancePage() { + const { user } = useUser(); const [employees, setEmployees] = useState([]); const [employeeSearch, setEmployeeSearch] = useState(""); const [selectedEmployeeIds, setSelectedEmployeeIds] = useState([]); const [periodPreset, setPeriodPreset] = useState("week"); const [periodStart, setPeriodStart] = useState(() => getPeriodStart("week")); const [periodEnd, setPeriodEnd] = useState(() => toDateInputValue(new Date())); - const [useManualSchedule, setUseManualSchedule] = useState(false); - const [scheduleStart, setScheduleStart] = useState("09:00"); - const [scheduleEnd, setScheduleEnd] = useState("18:00"); + const [scheduleStart, setScheduleStart] = useState("08:00"); + const [scheduleEnd, setScheduleEnd] = useState("17:00"); const [expectedHours, setExpectedHours] = useState("9"); const [workdays, setWorkdays] = useState(defaultWorkdays); - const [selectorOpen, setSelectorOpen] = useState(true); const [loadingEmployees, setLoadingEmployees] = useState(true); + const [loadingSchedule, setLoadingSchedule] = useState(true); + const [savingSchedule, setSavingSchedule] = useState(false); const [loadingStats, setLoadingStats] = useState(false); + const [downloadingReport, setDownloadingReport] = useState(false); const [error, setError] = useState(null); + const [scheduleFeedback, setScheduleFeedback] = useState(null); const [matrices, setMatrices] = useState([]); const [activeMonthIndex, setActiveMonthIndex] = useState(0); const [visibleEmployeePage, setVisibleEmployeePage] = useState(0); @@ -204,12 +313,23 @@ export default function AttendancePage() { employee: MonthlyAttendanceMatrixEmployee; cell: MonthlyAttendanceMatrixCell; } | null>(null); + const [commentsRecordPreview, setCommentsRecordPreview] = + useState(null); + const [dayEventsRecordPreview, setDayEventsRecordPreview] = + useState(null); + const [openedRecordFromUrl, setOpenedRecordFromUrl] = useState(false); const [statsModalOpen, setStatsModalOpen] = useState(false); + const canManageAttendance = Boolean(user?.auth?.is_staff || user?.auth?.is_superuser); useEffect(() => { let cancelled = false; async function loadEmployees() { + if (!canManageAttendance) { + setLoadingEmployees(false); + return; + } + try { setLoadingEmployees(true); setError(null); @@ -235,7 +355,44 @@ export default function AttendancePage() { return () => { cancelled = true; }; - }, []); + }, [canManageAttendance]); + + useEffect(() => { + let cancelled = false; + + async function loadStandardSchedule() { + if (!canManageAttendance) { + setLoadingSchedule(false); + return; + } + + try { + setLoadingSchedule(true); + const schedule = await apiClient.getStandardWorkSchedule(); + if (cancelled) return; + setScheduleStart(toTimeInputValue(schedule.start_time) || "08:00"); + setScheduleEnd(toTimeInputValue(schedule.end_time) || "17:00"); + setExpectedHours(String(schedule.expected_hours ?? 9)); + setWorkdays( + Array.isArray(schedule.workdays) && schedule.workdays.length > 0 + ? schedule.workdays + : defaultWorkdays, + ); + } catch (scheduleError) { + if (!cancelled) { + setError(getErrorMessage(scheduleError, "Не удалось загрузить стандартный график")); + } + } finally { + if (!cancelled) setLoadingSchedule(false); + } + } + + void loadStandardSchedule(); + + return () => { + cancelled = true; + }; + }, [canManageAttendance]); useEffect(() => { function updateMatrixEmployeesPerPage() { @@ -249,6 +406,45 @@ export default function AttendancePage() { return () => window.removeEventListener("resize", updateMatrixEmployeesPerPage); }, []); + useEffect(() => { + if (!canManageAttendance || openedRecordFromUrl) return; + const params = new URLSearchParams(window.location.search); + if (params.get("events") !== "1") return; + const recordId = Number(params.get("record")); + if (!Number.isInteger(recordId) || recordId <= 0) return; + + let cancelled = false; + setOpenedRecordFromUrl(true); + + async function openRecordFromUrl() { + try { + setError(null); + const record = await apiClient.getAttendanceRecord(recordId); + if (cancelled) return; + setDayEventsRecordPreview({ + recordId, + employeeName: record.display_name || `Сотрудник ${record.employee || record.employee_id || recordId}`, + date: `${record.date || ""}${record.date ? ` · ${weekdayLabelFromDate(record.date)}` : ""}`, + statusLabel: attendanceRecordStatusLabel(record), + displayText: attendanceRecordDisplay(record), + detailLines: attendanceRecordDetails(record), + issues: attendanceRecordIssueLabels(record), + isManuallyEdited: Boolean(record.is_manually_edited), + }); + } catch (loadError) { + if (!cancelled) { + setError(getErrorMessage(loadError, "Не удалось открыть запись посещаемости")); + } + } + } + + void openRecordFromUrl(); + + return () => { + cancelled = true; + }; + }, [canManageAttendance, openedRecordFromUrl]); + const filteredEmployees = useMemo(() => { const query = employeeSearch.trim().toLowerCase(); if (!query) return employees; @@ -322,6 +518,7 @@ export default function AttendancePage() { } function toggleWorkday(day: string) { + setScheduleFeedback(null); setWorkdays((current) => current.includes(day) ? current.filter((currentDay) => currentDay !== day) @@ -340,7 +537,6 @@ export default function AttendancePage() { } function getSchedulePayload(): AttendanceSchedulePayload | undefined { - if (!useManualSchedule) return undefined; return { start_time: scheduleStart, end_time: scheduleEnd, @@ -351,7 +547,43 @@ export default function AttendancePage() { }; } - function validateStatsRequest() { + async function saveStandardSchedule() { + const hours = Number(expectedHours); + if (!scheduleStart || !scheduleEnd || !Number.isFinite(hours) || hours <= 0 || hours > 24) { + setScheduleFeedback(null); + setError("Проверьте стандартный график"); + return; + } + if (workdays.length === 0) { + setScheduleFeedback(null); + setError("Выберите хотя бы один рабочий день"); + return; + } + + try { + setSavingSchedule(true); + setError(null); + setScheduleFeedback(null); + const schedule = await apiClient.updateStandardWorkSchedule({ + start_time: scheduleStart, + end_time: scheduleEnd, + expected_hours: hours, + workdays, + date_overrides: [], + }); + setScheduleStart(toTimeInputValue(schedule.start_time) || scheduleStart); + setScheduleEnd(toTimeInputValue(schedule.end_time) || scheduleEnd); + setExpectedHours(String(schedule.expected_hours ?? hours)); + setWorkdays(schedule.workdays?.length ? schedule.workdays : workdays); + setScheduleFeedback("Стандартный график сохранен"); + } catch (saveError) { + setError(getErrorMessage(saveError, "Не удалось сохранить стандартный график")); + } finally { + setSavingSchedule(false); + } + } + + function validateSelectionAndPeriod() { if (selectedEmployees.length === 0) { setError("Выберите хотя бы одного сотрудника"); return false; @@ -365,6 +597,50 @@ export default function AttendancePage() { return true; } + function validateStatsRequest() { + if (!validateSelectionAndPeriod()) return false; + + const hours = Number(expectedHours); + if (!scheduleStart || !scheduleEnd || !Number.isFinite(hours) || hours <= 0 || hours > 24) { + setError("Проверьте стандартный график"); + return false; + } + + if (workdays.length === 0) { + setError("Выберите хотя бы один рабочий день"); + return false; + } + + return true; + } + + async function downloadReport() { + setError(null); + if (!validateSelectionAndPeriod()) return; + + try { + setDownloadingReport(true); + const employeeIds = selectedEmployees.map((employee) => employee.id).join(","); + const file = await apiClient.downloadMonthlyAttendanceMatrix({ + employee_ids: employeeIds, + period_start: periodStart, + period_end: periodEnd, + }); + const url = URL.createObjectURL(file.blob); + const link = document.createElement("a"); + link.href = url; + link.download = file.filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (downloadError) { + setError(getErrorMessage(downloadError, "Не удалось скачать отчет посещаемости")); + } finally { + setDownloadingReport(false); + } + } + async function loadStatistics(options?: { refreshFromAnalyzer?: boolean }) { setError(null); setMatrices([]); @@ -409,33 +685,154 @@ export default function AttendancePage() { } } + function updateSelectedCellCommentCount(recordId: number, count: number) { + setSelectedCell((current) => { + if (!current || current.cell.record_id !== recordId) return current; + if (current.cell.comments_count === count) return current; + return { + ...current, + cell: { + ...current.cell, + comments_count: count, + detail_lines: [ + ...(current.cell.detail_lines || []).filter( + (line) => !String(line).startsWith("Комментарии:"), + ), + ...(count ? [`Комментарии: ${count}`] : []), + ], + }, + }; + }); + setMatrices((current) => { + let changed = false; + const nextMatrices = current.map((matrix) => ({ + ...matrix, + rows: matrix.rows.map((row) => ({ + ...row, + cells: Object.fromEntries( + Object.entries(row.cells).map(([employeeId, cell]) => { + if (cell.record_id !== recordId || cell.comments_count === count) { + return [employeeId, cell]; + } + changed = true; + return [employeeId, { ...cell, comments_count: count }]; + }), + ), + })), + })); + return changed ? nextMatrices : current; + }); + setCommentsRecordPreview((current) => ( + current?.recordId === recordId && current.commentsCount !== count + ? { ...current, commentsCount: count } + : current + )); + } + + function openCellComments( + row: MonthlyAttendanceMatrixRow, + employee: MonthlyAttendanceMatrixEmployee, + cell: MonthlyAttendanceMatrixCell, + ) { + if (!cell.record_id) return; + setCommentsRecordPreview({ + recordId: cell.record_id, + employeeName: employee.name, + date: `${row.date} · ${row.weekday}`, + statusLabel: cell.primary_label || matrixStatusLabel(cell.status), + displayText: matrixCellDisplay(cell) || "Нет записи", + detailLines: cell.detail_lines || [], + commentsCount: cell.comments_count, + }); + } + + function openSelectedCellComments() { + if (!selectedCell) return; + openCellComments(selectedCell.row, selectedCell.employee, selectedCell.cell); + } + + function openCellDayEvents( + row: MonthlyAttendanceMatrixRow, + employee: MonthlyAttendanceMatrixEmployee, + cell: MonthlyAttendanceMatrixCell, + ) { + if (!cell.record_id) return; + setDayEventsRecordPreview({ + recordId: cell.record_id, + employeeName: employee.name, + date: `${row.date} · ${row.weekday}`, + 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), + }); + } + + if (!canManageAttendance) { + return ( + +
+
+ +
+

+ Посещаемость +

+

+ Раздел доступен только сотрудникам с правами администрирования. + Личную посещаемость можно посмотреть в профиле. +

+
+
+
+
+ ); + } + return (
-
- +
+ + + +
+
- {selectorOpen ? ( -
-
+
- - - {useManualSchedule ? ( -
-
- - - -
- -
- {weekdays.map((day) => { - const active = workdays.includes(day.value); - return ( - - ); - })} -
+
+
+

+ Стандартный график анализа +

+

+ Сохраняется на сервере и применяется, если у сотрудника нет индивидуального графика. +

- ) : null} -
+ +
-
- - + {loadingSchedule ? ( +

+ + Загрузка стандартного графика +

+ ) : null} + +
+
+ + + +
+ +
+ {weekdays.map((day) => { + const active = workdays.includes(day.value); + return ( + + ); + })} +
+ + {scheduleFeedback ? ( +

+ {scheduleFeedback} +

+ ) : null} +
-
{error ? ( -
+
{error}
) : null} -
- ) : null} +
@@ -791,7 +1195,6 @@ export default function AttendancePage() { Дата @@ -799,29 +1202,12 @@ export default function AttendancePage() { {visibleEmployees.map((employee) => ( {employee.name} ))} - - {visibleEmployees.map((employee) => ( - - - Приход - - - Уход - - - ))} - {activeMatrix.rows.map((row) => ( @@ -838,36 +1224,60 @@ export default function AttendancePage() { const active = selectedCell?.row.date === row.date && selectedCell.employee.id === employee.id; return ( - - + +
- - - - - + {cell.comments_count ? ( + + ) : cell.record_id ? ( + + ) : null} + {cell.is_manually_edited ? ( + + ) : null} +
+ ); })} @@ -880,7 +1290,6 @@ export default function AttendancePage() { {visibleEmployees.map((employee) => ( {summaryRow.values[String(employee.id)] ?? 0} @@ -904,9 +1313,21 @@ export default function AttendancePage() {

- {matrixStatusLabel(selectedCell.cell.status)} + {selectedCell.cell.primary_label || matrixStatusLabel(selectedCell.cell.status)}
+
+

+ {matrixCellDisplay(selectedCell.cell) || "Нет записи"} +

+ {selectedCell.cell.detail_lines?.length ? ( +
+ {selectedCell.cell.detail_lines.map((line) => ( +

{line}

+ ))} +
+ ) : null} +

Приход

@@ -945,6 +1366,28 @@ export default function AttendancePage() { ))}
) : null} + {selectedCell.cell.is_manually_edited ? ( +
+ Ручная корректировка EUSRR +
+ ) : null} + {selectedCell.cell.record_id ? ( +
+ +
+ ) : null}
) : (

@@ -955,6 +1398,18 @@ export default function AttendancePage() { )}

+ setCommentsRecordPreview(null)} + onCommentCountChange={updateSelectedCellCommentCount} + record={commentsRecordPreview} + /> + setDayEventsRecordPreview(null)} + record={dayEventsRecordPreview} + />
); } diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 522473e2..fe408bb5 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -317,8 +317,14 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa function LeftNavContent({ onNavigate }: LeftNavContentProps) { const pathname = usePathname(); + const { user } = useUser(); const { notifications: notificationsData, markCategoryAsRead } = useNotifications(); const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]); + const canManageAttendance = Boolean(user?.auth?.is_staff || user?.auth?.is_superuser); + const visibleNavItems = useMemo( + () => navItems.filter((item) => item.href !== "/attendance" || canManageAttendance), + [canManageAttendance], + ); // Подсчет уведомлений по категориям const categoryCounts = useMemo(() => { @@ -355,7 +361,7 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) { return (
- {navItems.map(({ href, label, icon: Icon, category }) => { + {visibleNavItems.map(({ href, label, icon: Icon, category }) => { const count = category ? categoryCounts[category] || 0 : 0; return ( diff --git a/frontend/src/components/attendance/AttendanceDayEventsModal.tsx b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx new file mode 100644 index 00000000..f8bad446 --- /dev/null +++ b/frontend/src/components/attendance/AttendanceDayEventsModal.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Camera, Clock3, ImageOff, Loader2 } from "lucide-react"; + +import { Modal } from "@/components/ui/Modal"; +import { apiClient } from "@/lib/api"; +import type { AttendanceDayEvent } from "@/lib/api/attendance"; + +export type AttendanceDayEventsPreview = { + recordId: number; + employeeName: string; + date: string; + statusLabel: string; + displayText: string; + detailLines?: string[]; + issues?: string[]; + isManuallyEdited?: boolean; +}; + +type AttendanceDayEventsModalProps = { + isOpen: boolean; + onClose: () => void; + record: AttendanceDayEventsPreview | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +function revokePhotoUrls(urls: Record) { + Object.values(urls).forEach((url) => URL.revokeObjectURL(url)); +} + +export function AttendanceDayEventsModal({ + isOpen, + onClose, + record, +}: AttendanceDayEventsModalProps) { + const [events, setEvents] = useState([]); + const [photoUrls, setPhotoUrls] = useState>({}); + const [loadingEvents, setLoadingEvents] = useState(false); + const [loadingPhotos, setLoadingPhotos] = useState(false); + const [error, setError] = useState(null); + const photoUrlsRef = useRef>({}); + + useEffect(() => { + photoUrlsRef.current = photoUrls; + }, [photoUrls]); + + useEffect(() => { + if (!isOpen || !record?.recordId) return; + let cancelled = false; + const recordId = record.recordId; + + async function loadEvents() { + revokePhotoUrls(photoUrlsRef.current); + setEvents([]); + setPhotoUrls({}); + setError(null); + setLoadingEvents(true); + setLoadingPhotos(false); + + try { + const nextEvents = await apiClient.getAttendanceRecordDayEvents(recordId); + if (cancelled) return; + setEvents(nextEvents); + + const eventsWithPhotos = nextEvents.filter( + (event) => event.has_photo && event.photo_url, + ); + if (eventsWithPhotos.length === 0) return; + + setLoadingPhotos(true); + const loadedEntries = await Promise.all( + eventsWithPhotos.map(async (event) => { + const blob = await apiClient.getAttendanceDayEventPhoto(event.photo_url as string); + return [event.event_key, URL.createObjectURL(blob)] as const; + }), + ); + if (cancelled) { + loadedEntries.forEach(([, url]) => URL.revokeObjectURL(url)); + return; + } + setPhotoUrls(Object.fromEntries(loadedEntries)); + } catch (loadError) { + if (!cancelled) { + setError(getErrorMessage(loadError, "Не удалось загрузить события дня")); + } + } finally { + if (!cancelled) { + setLoadingEvents(false); + setLoadingPhotos(false); + } + } + } + + void loadEvents(); + + return () => { + cancelled = true; + }; + }, [isOpen, record?.recordId]); + + useEffect(() => ( + () => { + revokePhotoUrls(photoUrlsRef.current); + } + ), []); + + const close = () => { + revokePhotoUrls(photoUrlsRef.current); + setPhotoUrls({}); + onClose(); + }; + + return ( + + {record ? ( +
+
+

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

+
+
+

+ {record.employeeName} +

+

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

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

{line}

+ ))} +
+ ) : null} + {record.issues?.length ? ( +
+ {record.issues.map((issue) => ( + + {issue} + + ))} +
+ ) : null} + {record.isManuallyEdited ? ( +
+ Ручная корректировка EUSRR +
+ ) : null} +
+ +
+
+
+ +

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

+
+ {loadingPhotos ? ( + + + Загрузка фото + + ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : loadingEvents ? ( +
+ +

Загрузка событий...

+
+ ) : events.length === 0 ? ( +
+

+ Событий за день нет +

+

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

+
+ ) : ( +
+ {events.map((event) => { + const photoUrl = photoUrls[event.event_key]; + return ( +
+
+ {photoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {`${event.caption}, + ) : ( +
+ + Фото не сохранено +
+ )} +
+
+
+

+ {event.caption} +

+ + + {event.time_label} + +
+

+ {event.device_name || event.device || "Устройство"} · #{event.serial_no} +

+
+
+ ); + })} +
+ )} +
+
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx b/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx new file mode 100644 index 00000000..b7ebe1df --- /dev/null +++ b/frontend/src/components/attendance/AttendanceRecordCommentsModal.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Loader2, MessageSquare, Send } from "lucide-react"; + +import { CommentDeleteButton } from "@/components/shared/CommentControls"; +import { RequestAvatar } from "@/components/requests/RequestAvatar"; +import { Modal } from "@/components/ui/Modal"; +import { apiClient } from "@/lib/api"; +import { displayUserName, formatDateTime } from "@/lib/shared"; +import type { AttendanceRecordComment } from "@/lib/api/attendance"; + +export type AttendanceRecordCommentsPreview = { + recordId: number; + employeeName: string; + date: string; + statusLabel: string; + displayText: string; + detailLines?: string[]; + commentsCount?: number; +}; + +type AttendanceRecordCommentsModalProps = { + currentUserId?: number | null; + isOpen: boolean; + onClose: () => void; + onCommentCountChange?: (recordId: number, count: number) => void; + record: AttendanceRecordCommentsPreview | null; +}; + +function getErrorMessage(error: unknown, fallback: string) { + return String((error as Error)?.message || fallback); +} + +function commentAuthorName(comment: AttendanceRecordComment) { + return displayUserName(comment.author) || comment.author?.email || "Сотрудник"; +} + +function commentFallback(comment: AttendanceRecordComment) { + return ( + comment.author?.last_name?.[0] || + comment.author?.first_name?.[0] || + comment.author?.email?.[0] || + "К" + ).toUpperCase(); +} + +export function AttendanceRecordCommentsModal({ + currentUserId, + isOpen, + onClose, + onCommentCountChange, + record, +}: AttendanceRecordCommentsModalProps) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [actionId, setActionId] = useState(null); + const [composerExpanded, setComposerExpanded] = useState(false); + + const inputRef = useRef(null); + const commentsBottomRef = useRef(null); + const onCommentCountChangeRef = useRef(onCommentCountChange); + const activeRecordId = record?.recordId; + + useEffect(() => { + onCommentCountChangeRef.current = onCommentCountChange; + }, [onCommentCountChange]); + + const reset = useCallback(() => { + setDraft(""); + setError(null); + setActionId(null); + }, []); + + const scrollToBottom = useCallback((smooth = true) => { + requestAnimationFrame(() => { + commentsBottomRef.current?.scrollIntoView({ + behavior: smooth ? "smooth" : "auto", + block: "end", + }); + }); + }, []); + + const loadComments = useCallback(async (recordId: number) => { + try { + setLoading(true); + setError(null); + const nextComments = await apiClient.getAttendanceRecordComments(recordId); + setComments(nextComments); + onCommentCountChangeRef.current?.(recordId, nextComments.length); + } catch (loadError) { + setComments([]); + setError(getErrorMessage(loadError, "Не удалось загрузить комментарии")); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!isOpen || !activeRecordId) return; + reset(); + void loadComments(activeRecordId); + }, [activeRecordId, isOpen, loadComments, reset]); + + useEffect(() => { + if (!isOpen || loading) return; + scrollToBottom(false); + }, [comments.length, isOpen, loading, scrollToBottom]); + + useLayoutEffect(() => { + const input = inputRef.current; + if (!input) return; + const minHeight = 38; + const maxHeight = 128; + input.style.height = "0px"; + const nextHeight = Math.min(input.scrollHeight, maxHeight); + const resolvedHeight = Math.max(nextHeight, minHeight); + input.style.height = `${resolvedHeight}px`; + input.style.overflowY = input.scrollHeight > maxHeight ? "auto" : "hidden"; + setComposerExpanded(resolvedHeight > minHeight + 2); + }, [draft]); + + const submitComment = useCallback(async () => { + if (!record) return; + const text = draft.trim(); + if (!text) { + setError("Введите текст комментария"); + return; + } + try { + setSending(true); + setError(null); + const saved = await apiClient.addAttendanceRecordComment(record.recordId, text); + const nextComments = [...comments, saved]; + setComments(nextComments); + setDraft(""); + onCommentCountChange?.(record.recordId, nextComments.length); + scrollToBottom(); + } catch (submitError) { + setError(getErrorMessage(submitError, "Не удалось отправить комментарий")); + } finally { + setSending(false); + } + }, [comments, draft, onCommentCountChange, record, scrollToBottom]); + + const deleteComment = useCallback( + async (comment: AttendanceRecordComment) => { + if (!record) return; + try { + setActionId(comment.id); + await apiClient.deleteAttendanceRecordComment(record.recordId, comment.id); + const nextComments = comments.filter((item) => item.id !== comment.id); + setComments(nextComments); + onCommentCountChange?.(record.recordId, nextComments.length); + } catch (deleteError) { + setError(getErrorMessage(deleteError, "Не удалось удалить комментарий")); + } finally { + setActionId(null); + } + }, + [comments, onCommentCountChange, record], + ); + + return ( + + {record ? ( +
+
+
+

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

+
+
+
+

+ {record.employeeName} +

+

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

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

{line}

+ ))} +
+ ) : null} +
+ Комментариев: {Math.max(comments.length, record.commentsCount || 0)} +
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} +
+ +
+
+
+ +

Обсуждение

+
+ + {loading ? ( +
+ +

+ Загрузка комментариев... +

+
+ ) : comments.length === 0 ? ( +
+

+ Комментариев пока нет +

+

+ Добавьте пояснение к записи посещения. +

+
+ ) : ( +
+ {comments.map((comment) => { + const authorName = commentAuthorName(comment); + const isOwnComment = Boolean( + comment.author?.id && + currentUserId && + comment.author.id === currentUserId, + ); + return ( +
+
+ +
+
+
+

+ {authorName} +

+

+ {formatDateTime(comment.created_at)} +

+
+ {isOwnComment ? ( + void deleteComment(comment)} + /> + ) : null} +
+

+ {comment.text} +

+
+
+
+ ); + })} +
+
+ )} +
+
+ +
+
+
+