Improve attendance reporting and event details

This commit is contained in:
lizerk 2026-04-22 18:02:45 +03:00
parent 0ec0e1c75b
commit 8e11b7fbba
18 changed files with 2969 additions and 186 deletions

View File

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

View File

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

View File

@ -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/<int:employee_id>/",
EmployeeWorkScheduleAPIView.as_view(),
name="attendance-work-schedule",
),
path(
"attendance/standard-work-schedule/",
StandardWorkScheduleAPIView.as_view(),
name="attendance-standard-work-schedule",
),
path(
"attendance/records/<int:record_id>/",
AttendanceRecordDetailAPIView.as_view(),
name="attendance-record-detail",
),
path(
"attendance/records/<int:record_id>/day-events/",
AttendanceRecordDayEventsAPIView.as_view(),
name="attendance-record-day-events",
),
path(
"attendance/records/<int:record_id>/day-events/<str:event_key>/photo/",
AttendanceRecordDayEventPhotoAPIView.as_view(),
name="attendance-record-day-event-photo",
),
path(
"attendance/records/<int:record_id>/comments/",
AttendanceRecordCommentsAPIView.as_view(),

View File

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

View File

@ -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": "Стандартный график работы",
},
),
]

View File

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

View File

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

View File

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

View File

@ -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"] == []

View File

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

View File

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

View File

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

View File

@ -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<AttendancePeriodPreset, "custom">) {
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<string, string> = {
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<number, string> = {
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<User[]>([]);
const [employeeSearch, setEmployeeSearch] = useState("");
const [selectedEmployeeIds, setSelectedEmployeeIds] = useState<number[]>([]);
const [periodPreset, setPeriodPreset] = useState<AttendancePeriodPreset>("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<string[]>(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<string | null>(null);
const [scheduleFeedback, setScheduleFeedback] = useState<string | null>(null);
const [matrices, setMatrices] = useState<MonthlyAttendanceMatrix[]>([]);
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<AttendanceRecordCommentsPreview | null>(null);
const [dayEventsRecordPreview, setDayEventsRecordPreview] =
useState<AttendanceDayEventsPreview | null>(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 (
<AppShell>
<section className="app-surface mx-auto max-w-xl rounded-2xl p-6">
<div className="flex items-start gap-3">
<AlertTriangle size={18} className="mt-0.5 shrink-0 text-amber-400" />
<div>
<h1 className="text-base font-semibold text-[var(--foreground)]">
Посещаемость
</h1>
<p className="app-text-muted mt-2 text-sm">
Раздел доступен только сотрудникам с правами администрирования.
Личную посещаемость можно посмотреть в профиле.
</p>
</div>
</div>
</section>
</AppShell>
);
}
return (
<AppShell>
<div className="mx-auto flex max-w-6xl flex-col gap-4">
<section className="app-surface overflow-hidden rounded-2xl">
<button
type="button"
onClick={() => setSelectorOpen((current) => !current)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--surface-secondary)] sm:px-5"
aria-expanded={selectorOpen}
>
<div className="min-w-0">
<h1 className="text-base font-semibold text-[var(--foreground)] sm:text-lg">
Посещаемость
</h1>
<p className="app-text-muted mt-1 text-xs sm:text-sm">
<section className="app-surface rounded-2xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Посещаемость</p>
<p className="app-text-muted mt-1 text-xs">
Выбор сотрудников и периода для статистики посещаемости
</p>
</div>
<ChevronDown
size={18}
className={`shrink-0 text-[var(--foreground-muted)] transition-transform ${selectorOpen ? "rotate-180" : ""}`}
/>
</button>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void downloadReport()}
disabled={downloadingReport || loadingEmployees}
className="app-action-secondary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{downloadingReport ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
Скачать
</button>
<button
type="button"
onClick={() => void loadStatistics()}
disabled={loadingStats || loadingEmployees || loadingSchedule}
className="app-action-secondary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <CalendarCheck size={16} />}
Показать
</button>
<button
type="button"
onClick={() => void loadStatistics({ refreshFromAnalyzer: true })}
disabled={loadingStats || loadingEmployees || loadingSchedule}
className="app-action-primary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
Обновить
</button>
</div>
</div>
{selectorOpen ? (
<div className="border-t border-[var(--border-subtle)] px-4 py-4 sm:px-5">
<div className="max-w-[34rem]">
<div className="max-w-[34rem]">
<div className="app-surface-muted rounded-2xl p-3 sm:p-4">
<div className="relative">
<Search
@ -598,103 +995,110 @@ export default function AttendancePage() {
</div>
<div className="mt-4 app-surface-muted rounded-2xl p-3 sm:p-4">
<label className="flex items-center gap-3 text-sm font-medium text-[var(--foreground)]">
<input
type="checkbox"
checked={useManualSchedule}
onChange={(event) => setUseManualSchedule(event.target.checked)}
className="h-4 w-4 rounded border-[var(--border-subtle)]"
/>
Ручной график
</label>
{useManualSchedule ? (
<div className="mt-3 grid gap-3">
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="app-card-caption mb-2 block">Начало</span>
<input
type="time"
value={scheduleStart}
onChange={(event) => setScheduleStart(event.target.value)}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Конец</span>
<input
type="time"
value={scheduleEnd}
onChange={(event) => setScheduleEnd(event.target.value)}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Часы</span>
<input
type="number"
min="0"
step="0.25"
value={expectedHours}
onChange={(event) => setExpectedHours(event.target.value)}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
{weekdays.map((day) => {
const active = workdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => toggleWorkday(day.value)}
className={`h-9 min-w-10 rounded-lg px-3 text-sm font-medium transition ${
active
? "app-selected text-[var(--accent-primary)]"
: "app-surface text-[var(--muted-foreground)] hover:bg-[var(--surface-tertiary)]"
}`}
>
{day.label}
</button>
);
})}
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--foreground)]">
Стандартный график анализа
</p>
<p className="app-text-muted mt-1 text-xs">
Сохраняется на сервере и применяется, если у сотрудника нет индивидуального графика.
</p>
</div>
) : null}
</div>
<button
type="button"
onClick={() => void saveStandardSchedule()}
disabled={loadingSchedule || savingSchedule}
className="app-action-secondary inline-flex min-h-9 items-center justify-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{savingSchedule ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить
</button>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void loadStatistics()}
disabled={loadingStats || loadingEmployees}
className="app-action-secondary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <CalendarCheck size={16} />}
Показать статистику
</button>
<button
type="button"
onClick={() => void loadStatistics({ refreshFromAnalyzer: true })}
disabled={loadingStats || loadingEmployees}
className="app-action-primary inline-flex min-h-10 items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingStats ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
Обновить и открыть
</button>
{loadingSchedule ? (
<p className="app-text-muted mt-3 flex items-center gap-2 text-xs">
<Loader2 size={14} className="animate-spin" />
Загрузка стандартного графика
</p>
) : null}
<div className="mt-3 grid gap-3">
<div className="grid gap-3 sm:grid-cols-3">
<label className="block">
<span className="app-card-caption mb-2 block">Начало</span>
<input
type="time"
value={scheduleStart}
onChange={(event) => {
setScheduleStart(event.target.value);
setScheduleFeedback(null);
}}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Конец</span>
<input
type="time"
value={scheduleEnd}
onChange={(event) => {
setScheduleEnd(event.target.value);
setScheduleFeedback(null);
}}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="app-card-caption mb-2 block">Часы</span>
<input
type="number"
min="0"
step="0.25"
value={expectedHours}
onChange={(event) => {
setExpectedHours(event.target.value);
setScheduleFeedback(null);
}}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</label>
</div>
<div className="flex flex-wrap gap-2">
{weekdays.map((day) => {
const active = workdays.includes(day.value);
return (
<button
key={day.value}
type="button"
onClick={() => toggleWorkday(day.value)}
className={`h-9 min-w-10 rounded-lg px-3 text-sm font-medium transition ${
active
? "app-selected text-[var(--accent-primary)]"
: "app-surface text-[var(--muted-foreground)] hover:bg-[var(--surface-tertiary)]"
}`}
>
{day.label}
</button>
);
})}
</div>
{scheduleFeedback ? (
<p className="text-xs font-medium text-emerald-300">
{scheduleFeedback}
</p>
) : null}
</div>
</div>
</div>
{error ? (
<div className="mt-4 flex max-w-[34rem] items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
<div className="mt-4 flex items-start gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300">
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
<span>{error}</span>
</div>
) : null}
</div>
) : null}
</div>
</section>
</div>
@ -791,7 +1195,6 @@ export default function AttendancePage() {
<thead>
<tr className="bg-[var(--surface-secondary)] text-[var(--muted-foreground)]">
<th
rowSpan={2}
className="sticky left-0 z-20 min-w-28 border-b border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 font-semibold"
>
Дата
@ -799,29 +1202,12 @@ export default function AttendancePage() {
{visibleEmployees.map((employee) => (
<th
key={employee.id}
colSpan={2}
className="min-w-40 border-b border-r border-[var(--border-subtle)] px-3 py-2 text-center font-semibold text-[var(--foreground)]"
className="min-w-40 border-b border-r border-[var(--border-subtle)] px-3 py-3 text-center font-semibold text-[var(--foreground)]"
>
<span className="block max-w-44 truncate">{employee.name}</span>
</th>
))}
</tr>
<tr className="bg-[var(--surface-secondary)] text-[var(--muted-foreground)]">
{visibleEmployees.map((employee) => (
<Fragment key={employee.id}>
<th
className="min-w-20 border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center font-semibold"
>
Приход
</th>
<th
className="min-w-20 border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center font-semibold"
>
Уход
</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{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 (
<Fragment key={`${row.date}-${employee.id}`}>
<td
className="border-b border-r border-[var(--border-subtle)] p-0"
>
<td
key={`${row.date}-${employee.id}`}
className="border-b border-r border-[var(--border-subtle)] p-0"
>
<div className="relative h-10">
<button
type="button"
onClick={() => setSelectedCell({ row, employee, cell })}
className={`h-10 w-full px-2 text-center transition ${matrixCellTone(cell.status)} ${
onClick={() => {
setSelectedCell({ row, employee, cell });
openCellDayEvents(row, employee, cell);
}}
className={`h-10 w-full px-2 text-center transition ${matrixCellTone(cell.status)} ${matrixCellSourceTone(cell)} ${
active ? "ring-2 ring-inset ring-[var(--accent-primary)]" : ""
}`}
title={matrixStatusLabel(cell.status)}
title={cell.primary_label || matrixStatusLabel(cell.status)}
>
{formatTime(cell.arrival_time)}
<span className="block truncate font-medium">
{matrixCellDisplay(cell)}
</span>
</button>
</td>
<td
className="border-b border-r border-[var(--border-subtle)] p-0"
>
<button
type="button"
onClick={() => setSelectedCell({ row, employee, cell })}
className={`h-10 w-full px-2 text-center transition ${matrixCellTone(cell.status)} ${
active ? "ring-2 ring-inset ring-[var(--accent-primary)]" : ""
}`}
title={matrixStatusLabel(cell.status)}
>
{formatTime(cell.departure_time)}
</button>
</td>
</Fragment>
{cell.comments_count ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedCell({ row, employee, cell });
openCellComments(row, employee, cell);
}}
className="absolute right-1 top-1 rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[9px] leading-3 text-[var(--muted-foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--foreground)]"
aria-label={`Открыть комментарии: ${cell.comments_count}`}
title="Открыть комментарии"
>
К:{cell.comments_count}
</button>
) : cell.record_id ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setSelectedCell({ row, employee, cell });
openCellComments(row, employee, cell);
}}
className="absolute bottom-1 left-1 inline-flex h-4 min-w-4 items-center justify-center rounded border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-1 text-[10px] leading-3 text-[var(--muted-foreground)] transition hover:border-[var(--accent-primary)] hover:text-[var(--foreground)]"
aria-label="Добавить комментарий"
title="Добавить комментарий"
>
+
</button>
) : null}
{cell.is_manually_edited ? (
<span className="absolute bottom-1 right-1 h-1.5 w-1.5 rounded-full bg-violet-300" />
) : null}
</div>
</td>
);
})}
</tr>
@ -880,7 +1290,6 @@ export default function AttendancePage() {
{visibleEmployees.map((employee) => (
<td
key={`${summaryRow.key}-${employee.id}`}
colSpan={2}
className="border-b border-r border-[var(--border-subtle)] px-2 py-2 text-center font-semibold text-[var(--foreground)]"
>
{summaryRow.values[String(employee.id)] ?? 0}
@ -904,9 +1313,21 @@ export default function AttendancePage() {
</p>
</div>
<span className={`app-status-pill ${matrixCellTone(selectedCell.cell.status)}`}>
{matrixStatusLabel(selectedCell.cell.status)}
{selectedCell.cell.primary_label || matrixStatusLabel(selectedCell.cell.status)}
</span>
</div>
<div className="mt-3 rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2">
<p className="text-sm font-semibold text-[var(--foreground)]">
{matrixCellDisplay(selectedCell.cell) || "Нет записи"}
</p>
{selectedCell.cell.detail_lines?.length ? (
<div className="mt-2 grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{selectedCell.cell.detail_lines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
</div>
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-4">
<div>
<p className="app-card-caption">Приход</p>
@ -945,6 +1366,28 @@ export default function AttendancePage() {
))}
</div>
) : null}
{selectedCell.cell.is_manually_edited ? (
<div className="mt-3 inline-flex rounded-lg border border-violet-400/30 bg-violet-500/10 px-2.5 py-1 text-xs font-medium text-violet-200">
Ручная корректировка EUSRR
</div>
) : null}
{selectedCell.cell.record_id ? (
<div className="mt-4 border-t border-[var(--border-subtle)] pt-4">
<button
type="button"
onClick={openSelectedCellComments}
className="app-action-secondary inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm"
>
<MessageSquare size={16} />
Комментарии
{selectedCell.cell.comments_count ? (
<span className="app-counter min-w-4 px-1 text-[10px] font-bold">
{selectedCell.cell.comments_count}
</span>
) : null}
</button>
</div>
) : null}
</div>
) : (
<p className="app-text-muted text-sm">
@ -955,6 +1398,18 @@ export default function AttendancePage() {
)}
</div>
</Modal>
<AttendanceRecordCommentsModal
currentUserId={user?.id}
isOpen={Boolean(commentsRecordPreview)}
onClose={() => setCommentsRecordPreview(null)}
onCommentCountChange={updateSelectedCellCommentCount}
record={commentsRecordPreview}
/>
<AttendanceDayEventsModal
isOpen={Boolean(dayEventsRecordPreview)}
onClose={() => setDayEventsRecordPreview(null)}
record={dayEventsRecordPreview}
/>
</AppShell>
);
}

View File

@ -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 (
<div className="app-surface rounded-2xl p-5">
<div className="space-y-2 text-sm">
{navItems.map(({ href, label, icon: Icon, category }) => {
{visibleNavItems.map(({ href, label, icon: Icon, category }) => {
const count = category ? categoryCounts[category] || 0 : 0;
return (

View File

@ -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<string, string>) {
Object.values(urls).forEach((url) => URL.revokeObjectURL(url));
}
export function AttendanceDayEventsModal({
isOpen,
onClose,
record,
}: AttendanceDayEventsModalProps) {
const [events, setEvents] = useState<AttendanceDayEvent[]>([]);
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
const [loadingEvents, setLoadingEvents] = useState(false);
const [loadingPhotos, setLoadingPhotos] = useState(false);
const [error, setError] = useState<string | null>(null);
const photoUrlsRef = useRef<Record<string, string>>({});
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 (
<Modal
isOpen={isOpen && !!record}
onClose={close}
title="События посещения"
size="xl"
>
{record ? (
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Запись посещения</p>
<div className="mt-2 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{record.detailLines?.length ? (
<div className="mt-3 grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{record.detailLines.slice(0, 8).map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
{record.issues?.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{record.issues.map((issue) => (
<span key={issue} className="app-status-pill bg-amber-500/15 text-amber-300">
{issue}
</span>
))}
</div>
) : null}
{record.isManuallyEdited ? (
<div className="mt-3 inline-flex rounded-lg border border-violet-400/30 bg-violet-500/10 px-2.5 py-1 text-xs font-medium text-violet-200">
Ручная корректировка EUSRR
</div>
) : null}
</section>
<section className="app-surface rounded-xl p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Camera size={16} className="app-text-muted" />
<p className="app-card-caption">События LogStorm за день</p>
</div>
{loadingPhotos ? (
<span className="app-text-muted inline-flex items-center gap-2 text-xs">
<Loader2 size={14} className="animate-spin" />
Загрузка фото
</span>
) : null}
</div>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : loadingEvents ? (
<div className="py-10 text-center">
<Loader2 className="mx-auto animate-spin text-[var(--muted-foreground)]" size={22} />
<p className="app-text-muted mt-3 text-sm">Загрузка событий...</p>
</div>
) : events.length === 0 ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Событий за день нет
</p>
<p className="app-text-muted mt-2 text-sm">
LogStorm не вернул проходы для этой даты.
</p>
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{events.map((event) => {
const photoUrl = photoUrls[event.event_key];
return (
<article
key={event.event_key}
className="app-surface-muted overflow-hidden rounded-xl"
>
<div className="aspect-video bg-[var(--surface-primary)]">
{photoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrl}
alt={`${event.caption}, ${event.time_label}`}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-2 text-[var(--muted-foreground)]">
<ImageOff size={24} />
<span className="text-xs">Фото не сохранено</span>
</div>
)}
</div>
<div className="space-y-2 p-3">
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-semibold text-[var(--foreground)]">
{event.caption}
</p>
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--foreground)]">
<Clock3 size={13} />
{event.time_label}
</span>
</div>
<p className="app-text-muted text-xs">
{event.device_name || event.device || "Устройство"} · #{event.serial_no}
</p>
</div>
</article>
);
})}
</div>
)}
</section>
</div>
) : null}
</Modal>
);
}

View File

@ -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<AttendanceRecordComment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [actionId, setActionId] = useState<number | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const commentsBottomRef = useRef<HTMLDivElement | null>(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 (
<Modal
isOpen={isOpen && !!record}
onClose={onClose}
title="Комментарии к посещению"
size="lg"
noPadding
>
{record ? (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 space-y-4 px-4 pb-4 sm:px-6">
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Запись посещения</p>
<div className="mt-2 space-y-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="app-text-wrap text-base font-semibold text-[var(--foreground)]">
{record.employeeName}
</h3>
<p className="app-text-muted mt-1 text-sm">
{record.date} · {record.displayText || "Нет записи"}
</p>
</div>
<span className="app-status-pill bg-[var(--surface-primary)] text-[var(--foreground)]">
{record.statusLabel}
</span>
</div>
{record.detailLines?.length ? (
<div className="grid gap-1 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
{record.detailLines.slice(0, 6).map((line) => (
<p key={line}>{line}</p>
))}
</div>
) : null}
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>Комментариев: {Math.max(comments.length, record.commentsCount || 0)}</span>
</div>
</div>
</section>
{error ? (
<div className="app-feedback-danger rounded-xl p-3 text-sm">
{error}
</div>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 sm:px-6">
<section className="app-surface rounded-xl p-4">
<div className="mb-3 flex items-center gap-2">
<MessageSquare size={16} className="app-text-muted" />
<p className="app-card-caption">Обсуждение</p>
</div>
{loading ? (
<div className="py-8 text-center">
<Loader2 className="mx-auto animate-spin text-[var(--muted-foreground)]" size={22} />
<p className="app-text-muted mt-3 text-sm">
Загрузка комментариев...
</p>
</div>
) : comments.length === 0 ? (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Комментариев пока нет
</p>
<p className="app-text-muted mt-2 text-sm">
Добавьте пояснение к записи посещения.
</p>
</div>
) : (
<div className="space-y-3">
{comments.map((comment) => {
const authorName = commentAuthorName(comment);
const isOwnComment = Boolean(
comment.author?.id &&
currentUserId &&
comment.author.id === currentUserId,
);
return (
<article
key={comment.id}
className="app-surface-muted rounded-xl p-3"
>
<div className="flex items-start gap-3">
<RequestAvatar
alt={authorName}
fallback={commentFallback(comment)}
size="lg"
src={comment.author?.avatar}
/>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--foreground)]">
{authorName}
</p>
<p className="app-text-muted mt-0.5 text-xs">
{formatDateTime(comment.created_at)}
</p>
</div>
{isOwnComment ? (
<CommentDeleteButton
disabled={actionId === comment.id}
onClick={() => void deleteComment(comment)}
/>
) : null}
</div>
<p className="app-text-wrap mt-2 text-sm leading-6 text-[var(--foreground)]">
{comment.text}
</p>
</div>
</div>
</article>
);
})}
<div ref={commentsBottomRef} />
</div>
)}
</section>
</div>
<div className="app-surface-elevated shrink-0 border-t border-[var(--border-primary)] px-4 py-4 sm:px-6">
<div className="flex items-end gap-2">
<div
className={`app-input min-w-0 flex-1 overflow-hidden shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] ${
composerExpanded ? "rounded-[1.5rem]" : "h-10 rounded-full"
}`}
>
<textarea
ref={inputRef}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
if (error) setError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void submitComment();
}
}}
rows={1}
placeholder="Введите комментарий..."
aria-label="Поле ввода комментария"
className={`w-full resize-none bg-transparent px-4 py-2.5 text-sm leading-5 text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)] ${
composerExpanded ? "rounded-[1.5rem]" : "rounded-full"
}`}
/>
</div>
<button
type="button"
onClick={() => void submitComment()}
disabled={sending || !draft.trim()}
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500 text-white leading-none shadow-sm transition hover:bg-sky-600 active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-sky-100 disabled:cursor-not-allowed disabled:border disabled:border-[var(--border-subtle)] disabled:bg-[var(--surface-secondary)] disabled:text-[var(--muted-foreground)] disabled:shadow-none"
title="Отправить комментарий"
aria-label="Отправить комментарий"
>
{sending ? <Loader2 size={15} className="animate-spin" /> : <Send size={15} />}
</button>
</div>
</div>
</div>
) : null}
</Modal>
);
}

View File

@ -1,4 +1,4 @@
import { buildQuery, type RequestFn } from './utils';
import { buildQuery, type GetTokenFn, type RequestFn } from './utils';
import type { User } from '@/types/api';
export type AttendanceDateOverridePayload = {
@ -28,6 +28,14 @@ export type EmployeeWorkSchedule = AttendanceSchedulePayload & {
updated_at?: string | null;
};
export type StandardWorkSchedule = AttendanceSchedulePayload & {
id: number | null;
is_default: boolean;
updated_by?: number | null;
created_at?: string | null;
updated_at?: string | null;
};
export type AttendanceAnalyzePayload = {
employee_id: number;
period_start: string;
@ -78,6 +86,18 @@ export type AttendanceRecordComment = {
created_at: string;
};
export type AttendanceDayEvent = {
event_key: string;
time: string;
time_label: string;
caption: string;
device: string;
device_name: string;
serial_no: number;
has_photo: boolean;
photo_url: string | null;
};
export type AttendanceAnalysisResponse = {
records?: AttendanceRecord[];
[key: string]: unknown;
@ -124,6 +144,10 @@ export type MonthlyAttendanceMatrixCell = {
work_hours: number | string | null;
expected_hours: number | string | null;
status: 'empty' | 'technical' | 'underwork' | 'late' | 'overtime' | 'absent' | 'non_working' | 'normal' | string;
short_label?: string;
display_text?: string;
primary_label?: string;
detail_lines?: string[];
issues: string[];
is_workday: boolean | null;
effective_is_workday: boolean | null;
@ -135,6 +159,8 @@ export type MonthlyAttendanceMatrixCell = {
is_absent?: boolean;
personnel_status?: string;
personnel_status_label?: string;
is_manually_edited?: boolean;
manual_edited_at?: string | null;
comments_count: number;
};
@ -171,7 +197,26 @@ export type MonthlyAttendanceMatrixQuery = {
month: string;
};
export function createAttendanceApi(request: RequestFn) {
export type MonthlyAttendanceMatrixExportQuery = {
employee_ids: string;
period_start: string;
period_end: string;
};
export type AttendanceMatrixExportFile = {
blob: Blob;
filename: string;
};
function filenameFromContentDisposition(value: string | null): string | null {
if (!value) return null;
const utfMatch = value.match(/filename\*=UTF-8''([^;]+)/i);
if (utfMatch?.[1]) return decodeURIComponent(utfMatch[1].replace(/"/g, ''));
const match = value.match(/filename="?([^";]+)"?/i);
return match?.[1] || null;
}
export function createAttendanceApi(request: RequestFn, getToken: GetTokenFn) {
return {
analyzeAttendance: (data: AttendanceAnalyzePayload) =>
request('/api/v1/attendance/logstorm/analyze/', {
@ -180,8 +225,44 @@ export function createAttendanceApi(request: RequestFn) {
}) as Promise<AttendanceAnalysisResponse>,
getAttendanceRecords: (params?: AttendanceRecordsQuery) =>
request(`/api/v1/attendance/records/${buildQuery(params)}`) as Promise<PaginatedAttendanceRecords>,
getAttendanceRecord: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/`) as Promise<AttendanceRecord>,
getMonthlyAttendanceMatrix: (params: MonthlyAttendanceMatrixQuery) =>
request(`/api/v1/attendance/monthly-matrix/${buildQuery(params)}`) as Promise<MonthlyAttendanceMatrix>,
downloadMonthlyAttendanceMatrix: async (
params: MonthlyAttendanceMatrixExportQuery,
): Promise<AttendanceMatrixExportFile> => {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`/api/v1/attendance/monthly-matrix/export/${buildQuery(params)}`, {
method: 'GET',
headers,
});
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
detail = payload.detail || JSON.stringify(payload);
} catch {
detail = response.statusText;
}
throw new Error(`API Error: ${response.status} ${detail}`);
}
return {
blob: await response.blob(),
filename: filenameFromContentDisposition(
response.headers.get('content-disposition'),
) || `attendance-${params.period_start}_${params.period_end}.xlsx`,
};
},
getStandardWorkSchedule: () =>
request('/api/v1/attendance/standard-work-schedule/') as Promise<StandardWorkSchedule>,
updateStandardWorkSchedule: (data: Partial<AttendanceSchedulePayload>) =>
request('/api/v1/attendance/standard-work-schedule/', {
method: 'PATCH',
body: JSON.stringify(data),
}) as Promise<StandardWorkSchedule>,
getEmployeeWorkSchedule: (employeeId: number | string) =>
request(`/api/v1/attendance/work-schedules/${employeeId}/`) as Promise<EmployeeWorkSchedule>,
updateEmployeeWorkSchedule: (
@ -199,6 +280,25 @@ export function createAttendanceApi(request: RequestFn) {
}) as Promise<AttendanceRecord>,
getAttendanceRecordComments: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/comments/`) as Promise<AttendanceRecordComment[]>,
getAttendanceRecordDayEvents: (recordId: number) =>
request(`/api/v1/attendance/records/${recordId}/day-events/`) as Promise<AttendanceDayEvent[]>,
getAttendanceDayEventPhoto: async (photoUrl: string): Promise<Blob> => {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(photoUrl, { method: 'GET', headers });
if (!response.ok) {
let detail = response.statusText;
try {
const payload = await response.json();
detail = payload.detail || payload.error || JSON.stringify(payload);
} catch {
detail = response.statusText;
}
throw new Error(`API Error: ${response.status} ${detail}`);
}
return response.blob();
},
addAttendanceRecordComment: (recordId: number, text: string) =>
request(`/api/v1/attendance/records/${recordId}/comments/`, {
method: 'POST',

View File

@ -30,7 +30,7 @@ export const apiClient = Object.assign(base, {
...createRequestsApi(req, tok),
...createEquipmentApi(req, tok),
...createProcurementApi(req),
...createAttendanceApi(req),
...createAttendanceApi(req, tok),
});
export default apiClient;