Improve attendance reporting and event details
This commit is contained in:
parent
0ec0e1c75b
commit
8e11b7fbba
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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")
|
||||
|
||||
92
backend/attendance/migrations/0005_standardworkschedule.py
Normal file
92
backend/attendance/migrations/0005_standardworkschedule.py
Normal 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": "Стандартный график работы",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"] == []
|
||||
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
244
frontend/src/components/attendance/AttendanceDayEventsModal.tsx
Normal file
244
frontend/src/components/attendance/AttendanceDayEventsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user